Compare commits

...
Author SHA1 Message Date
zeekayandClaude Opus 4.8 2257daa7da feat(world): opt-in React canary — serve dist-react behind sticky ?react cookie, default stays vanilla
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-24 13:02:00 -07:00
zeekayandClaude Opus 4.8 deb6c763ce chore(world): bump @hanzogui/shell 7.5.2 -> 7.6.1 (monochrome focus ring + world surface)
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-24 12:55:51 -07:00
zeekayandClaude Opus 4.8 de727b14cb feat(world): cutover-ready — full panel coverage, rail scroll+variant, dedup/globe-feed fixes, bundle split, OIDC callback, shell bump, React e2e
Integrate the world.hanzo.ai React cutover-readiness round and gate it.

Panels: wire the full ported catalog (58) into ONE lazy PanelRail chunk with the
per-variant filter (variantConfig DEFAULT_PANELS enabled-keys + alias/extra/always
skew maps). AiAnalystPanel routes set_variant through the one-switch path
(onVariantChange). status is always-visible; watch/stations-wall register but stay
opt-in (enabled:false).

Bundle split: App drops all eager panel imports; PanelRail/FinanceTerminal/AnalystDock
load behind Suspense (lazy.tsx). Entry gz 219.83 kB (< 350 target); PanelRail is its
own 91.55 kB chunk.

Fixes introduced by the ports: CascadePanel dead 'country' compare, InvestmentsPanel
unused import, StationsWall/WatchQueue gui-element ref casts, main.tsx host narrowing
into boot(). Backed out LiveWebcamsPanel — its port imported a fabricated
@/services/webcams data layer that does not exist (vanilla uses static YouTube embeds).

React e2e (cutover gate): playwright.react.config.ts serves the React entry
(/index.react.html) and runs 6 CORE specs green — shell/auth + variant tabs, globe
island canvas, variant filter swaps the panel set, shared panel-order honoured, real
pointer drag reorders + persists the shared key, MarketsPanel live fetch renders.

Gates all exit 0: typecheck:react, build:react, vanilla build.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-24 12:50:50 -07:00
zeekayandClaude Opus 4.8 3b420c702c feat(world): Stage 4-6 — controllers→hooks, finance terminal, real auth+telemetry (feature-complete, no cutover)
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-24 12:07:26 -07:00
zeekayandClaude Opus 4.8 4eec884d26 feat(world): Stage 2 — port 40 dashboard panels onto the chassis
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-24 09:35:07 -07:00
zeekayandClaude Fable 5 8c086a2bfa feat(world): AI-plane API — /v1/world/{events,conflicts,infra,vessel,news,markets,feeds} for the world-gw MCP/ZAP gateway
The endpoints the world-gw MCP/ZAP gateway proxies to were never built (404 in
prod). Implement them as thin, cache-first projections over the backend's
EXISTING sources — no second fetcher for any upstream:

- /v1/world/events: one aggregator (handlers_events.go) mapping existing
  sources to canonical ZAP topic records. Poll returns {events:[...]}; ?stream=1
  is a long-lived NDJSON stream (snapshot then re-poll, new-only dedupe by stable
  id, every specific record mirrored onto world.events.all, ~25s keepalive, ctx
  cancellation). Sources: UCDP GED→conflicts, USGS→earthquakes, FIRMS→fires,
  Open-Meteo climate→weather.alerts, curated RSS→news.live, CoinGecko→crypto,
  Yahoo→quotes. ais/opensky are honestly not aggregated server-side.
- /v1/world/conflicts: UCDP GED filtered by country/since/severity.
- /v1/world/infra: real provider/service-status board (shared serviceStatusBoard
  extraction); physical-infra geodata + geospatial 'near' honestly noted absent.
- /v1/world/vessel: AIS relay lookup; honest empty+note when unprovisioned.
- /v1/world/news: curated-feed headlines via the shared feed pipeline.
- /v1/world/markets: CoinGecko crypto + Yahoo quotes (equities/fx/commodities).
- /v1/world/feeds: ZAP topic catalog + backend feed categories.

Reuse one curated feed catalog (exported from internal/world/mcp). Fix asFloat to
coerce native int/int64 (UCDP deaths_best is a Go int; severity was always 0).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-23 22:55:20 -07:00
zeekayandClaude Opus 4.8 b25ef10a08 feat(world): PanelGrid rail + wire MarketsPanel onto the chassis
PanelGrid owns the rail layout, drag-to-reorder (reusing the vanilla
attachPanelDrag engine — ghost/FLIP/touch/Escape), and order persistence via
the shared panel-order key, so React and vanilla read the same saved layout.
MarketsPanel is re-authored onto the Panel chassis + a PanelGrid slot, reusing
fetchMultipleStocks/formatPrice/formatChange/sparkline verbatim (view-only
port). App renders it through PanelGrid; VariantTabs/AccountControl move to
longhands; the obsolete PanelCard is removed (chassis supersedes it).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-23 21:58:25 -07:00
zeekayandClaude Opus 4.8 ab7a3c5213 feat(world): panel chassis — the one @hanzo/gui Panel base analogue
Panel is the single chrome every ported panel renders into — the React
analogue of the vanilla `Panel` base: header/title/actions/dragHandle slots,
the four decomplected states (ready/loading/empty/error) with copy defaulting
to the same common.* i18n keys, optional tabs (reusing the vanilla PanelTab
shape), and a sparkline slot. Sparkline wraps the vanilla sparkline() util so
dense series draw identically. Primitive-based (YStack/XStack/SizableText/
Spinner/ScrollView), longhand-only, brand-token themed.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-23 21:58:18 -07:00
zeekayandClaude Opus 4.8 b54f61b8e8 fix(world): make gui config bundler-authoritative + share panel-order
Resolve the Stage-0 typecheck:react friction at its root: v4 defaultConfig
sets settings.onlyAllowShorthands:true, and registering it via GuiCustomConfig
strips every longhand style prop and re-derives the media wrapper as an index
signature JSX children cannot satisfy — the whole error cascade. Neither a
strictness knob nor a component wrapper removes it. So the runtime/bundler owns
the config (GuiProvider config={guiConfig}), tsc stays a clean structural gate,
and components use one longhand vocabulary. main.tsx inits the shared i18n
layer before paint. panel-order.ts is the ONE reader/writer for the vanilla
`panel-order` key, so React and vanilla share one saved layout.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-23 21:58:11 -07:00
zeekay 0d0b2eab4f feat(world): react entry — unified shell, globe island, variant tabs, markets panel 2026-07-23 20:51:33 -07:00
zeekay baed7eaa06 feat(world): react/gui foundation — deps, isolated vite/tsconfig, tamagui config 2026-07-23 20:51:28 -07:00
zeekayandClaude Fable 5 2f3bf8f4f6 release: v2.4.49 — CSS token integrity: dead declarations fixed, one amber knob, literal collapse
- fix: 20 dead style declarations (bare var(--undefined) → property silently dropped/
  inherited): transparent findings context menu, wrong-colored urgency borders, etc.
- refactor: yellow-family collapsed to ONE knob (--yellow per theme; --status-cached +
  --semantic-elevated reference it; #ffcc44 drift killed).
- polish: drifted color literals onto canonical tokens (audit §4).
- ci: native .hanzo/workflows build + git.hanzo.ai sync.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-23 14:53:06 -07:00
zeekayandClaude Fable 5 b20ea099b5 refactor(world): one amber knob — collapse the yellow-family drift
"Yellow" in the UI is the status amber (#ffaa00): stale/cached badges, elevated
threat, the legend's Monitoring/Nuclear markers. It was defined under THREE token
names per theme (--yellow, --status-cached, --semantic-elevated) plus a brighter
#ffcc44 drifting raw in the chain-nodes stale/provisional states.

Now: --yellow is the single palette knob (#ffaa00 dark / #d97706 light);
--status-cached and --semantic-elevated reference it; the 4 #ffcc44 literals
route through var(--semantic-elevated). Changing (or killing) the amber
brand-wide is now a one-line edit per theme.

Left alone deliberately: the separate 5-tier --threat-* scale (own palette,
scale-to-scale merge = visible change, deferred to the screenshot-diffed pass)
and TS/WebGL literals (canvas can't consume CSS vars; values match the token).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-23 14:52:46 -07:00
zeekayandClaude Fable 5 f6bf038fa1 fix(world): 20 dead style declarations — bare var(--undefined) rewritten to real tokens
A bare var(--x) with no fallback where --x is never defined makes the whole
declaration invalid at computed-value time — the property silently becomes
inherit/initial. 12 phantom token names (--orange, --muted, --text-primary,
--danger, --success, --warning, --blue, --accent-red, --surface-light,
--surface-elevated, --bg-panel, --bg-hover) were used bare in 20 declarations,
so those styles never applied: the intel-findings context menu rendered with NO
background (initial = transparent), focal-point/convergence urgency borders fell
to currentColor instead of orange, findings-modal titles inherited, etc.

Rewrote each to its semantics-matched canonical token from the existing :root
vocabulary (--text-muted, --text, --red, --green, --yellow, --semantic-high,
--semantic-info, --surface-hover, --surface-active, --panel-bg-solid) — the one
vocabulary, theme-aware in both light and dark. Dynamic runtime knobs with
fallbacks (--marker-scale, --flash-duration, …) are intentional and untouched.

Same bug class as the rgba(var(--semantic-critical)) fills fixed in v2.4.46.
Build green; CSS-only.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-23 14:47:57 -07:00
zeekayandClaude Opus 4.8 e5f2e3bd80 polish(world): collapse drifted color literals onto their canonical tokens (audit §4)
Pure indirection, zero visual change: the token EQUALS the literal, so rendering is
byte-identical — this just removes the drift so future color changes go through one
place. Replaced usage-site `#ffaa00` → var(--yellow) and `#44ff88` → var(--green)
(the --status-cached/--status-live/--yellow/--green DEFINITIONS untouched). Left
`#ffcc44` alone — it's a distinct value, so mapping it would change the color (that's
the risky consolidation, deferred to the deliberate scale pass).

Build green. This is the safe core of audit §6; the type/radius/z-index SCALE
consolidation (which changes values → needs per-view screenshot diffing) stays a
separate deliberate pass.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-23 14:40:06 -07:00
hanzo-dev 3f421144a8 ci: native .hanzo/workflows build + git.hanzo.ai sync (additive) 2026-07-23 09:29:07 -07:00
hanzo-dev 7deedeb737 release: v2.4.48 — name the two Insights surfaces on the @hanzo/event base: BriefPanel + InsightsPanel
Rides on the native @hanzo/event telemetry (one client → POST /v1/event). Renames
the AI news-brief panel to BriefPanel (it makes the world brief) and the
product-analytics panel over /v1/insights/events to InsightsPanel, dropping the
Org prefix and the name collision. GTM stays orthogonal. No duplicate telemetry.

Claude-Session: https://claude.ai/code/session_01NP4ehjWW2h98FkE4YjUKuJ
2026-07-23 09:25:45 -07:00
Hanzo Dev 387562318c world: native @hanzo/event telemetry — one client, subsumes Sentry + Umami
Converge world's client telemetry on the ONE canonical Hanzo Cloud front
door: pageviews, product events, and errors all leave through @hanzo/event
to POST /v1/event, lensed server-side into web analytics, product analytics,
and error tracking. This supersedes v2.4.47's fragmented client stack — the
third-party Sentry bundle and the direct analytics.hanzo.ai (Umami) page
script both fed lenses the /v1/event stream now feeds server-side, so loading
them client-side double-counted the same lenses.

- bootstrap/telemetry.ts: the one client. Framework-agnostic createAnalytics,
  brand-resolved host (hanzo/lux/zoo), IAM bearer injected at the composition
  root (else a write-only publishable key so the fail-closed door accepts
  logged-out ingest), consent gate (DNT / GPC / opt-out), SPA pageview on
  pathname change, and filtered global error capture that preserves the
  curated WebGL/map noise-filter verbatim.
- Product moments captured with the shared EVENTS vocabulary: country
  drill-down, map-layer toggle, variant switch, share, and the AI analyst
  chat funnel (start + per message). identify/group on the signed-in session.
- Remove @sentry/browser + bootstrap/sentry.ts; drop the Umami injection +
  track() from bootstrap/analytics.ts. GTM (marketing tags) stays — an
  orthogonal, env-gated concern @hanzo/event does not cover.

Logged-out marketing/public views load it too — anonymous, consent-gated.
typecheck + vite build + data tests green.
2026-07-22 22:45:44 -07:00
zeekayandClaude Opus 4.8 0d4a477661 refactor(world): extract CountryIntelController from App.ts
Move the fullscreen country view out of the App god-object into its own
unit: the CountryBriefPage lifecycle + map country-click wiring, brief
generation (local CII + server intel + ML fallback), the seven-day
timeline, per-country signal counts, the shareable story modal, and the
country-name/geo static helpers (COUNTRY_BOUNDS / COUNTRY_ALIASES /
resolveCountryName / …). The controller owns countryBriefPage /
countryTimeline / briefRequestToken; App composes it with lazy accessors
(map / panels / news / clusters / predictions / intelligenceCache) plus
getShareUrl + buildAnalystHost callbacks. Behavior byte-for-byte
identical — the map registers onCountryClicked exactly once on the stable
this.map, same as before.

App keeps thin delegations (setupCountryIntel / openCountryBrief /
openCountryBriefByCode / openCountryStory) so every call site — the map
handler, search, deep links, share buttons, window.__app — is unchanged,
and re-exposes window.__app.countryBriefPage (read-only getter) for the
e2e boot-readiness hook. App.ts 5686 → 4747 lines across the three
extractions; 18 now-unused imports pruned.

Build green; variant-switch-inplace + stations-wall e2e green; 3/3
direct-drive country-view tests green (the deep-link case is gated on
real proxied-feed freshness and fails identically on main — environmental,
not a regression, verified by stash+rerun).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-22 19:00:57 -07:00
hanzo-dev 9bdc72b909 release: v2.4.47 — instrument world: Sentry + analytics.hanzo.ai (Umami) + GTM-ready, clean AnalyticsPanel/analytics naming
Wire world's telemetry to the live Hanzo observability services, all env-gated
from KMS (no-op until IDs provisioned — ships inert, activates on config):
- Sentry: VITE_SENTRY_DSN build-secret → errors to sentry.hanzo.ai.
- Analytics: bootstrap/analytics.ts injects the analytics.hanzo.ai (Umami) script
  on VITE_ANALYTICS_WEBSITE_ID; track() forwards custom product events (→
  /v1/insights/events + AnalyticsPanel pageviews). track('variant_view') wired.
- GTM-ready: initGtm() loads one Google Tag Manager container (VITE_GTM_ID) that
  manages GA / Facebook / LinkedIn / X tags from GTM's UI; CSP script-src allows
  googletagmanager/google-analytics/facebook/linkedin/x.
- Naming: CloudAnalyticsPanel → AnalyticsPanel, org-analytics.ts → analytics.ts.
OpenGraph/Twitter/JSON-LD SEO already comprehensive.

Claude-Session: https://claude.ai/code/session_01NP4ehjWW2h98FkE4YjUKuJ
2026-07-22 18:49:21 -07:00
zeekayandClaude Opus 4.8 a061b048c7 refactor(world): extract AnalystCommandHost from App.ts
Move the agentic-control surface out of App into its own unit. App
exposes a narrow capability port (AnalystHostBridge); AnalystCommandHost
owns the SHAPE of what the AI analyst can see and do — the getState /
listPanels / listLayers projections, the one-time org priming, and the
1:1 command routing that builds the AnalystHost object. The command
implementations stay in App (they mutate shared panel/monitor/variant/
layer state and are called from UI paths too) and are reached through the
bridge — a thin adapter, behavior byte-for-byte identical.

buildAnalystHost() is now a one-line delegation to the host's build();
all three call sites (country brief, analyst panel, analyst dock) are
unchanged. Build + variant-switch-inplace + stations-wall e2e green.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-22 18:15:48 -07:00
zeekayandClaude Opus 4.8 c5af34eb82 release: v2.4.46 — polish pass: globe-crash fix, dropdown coverage, −1.6k dead code, a11y, decomplect
Ships the polish batch that has been sitting on main since v2.4.45:
- fix: free the parked-mapbox GPU context in backgrounded tabs (Safari multi-tab
  black-globe crash — confirmed single-tab prod renders fine; the crash is context
  exhaustion across tabs, now mitigated).
- fix: News Wall + Markets Bubble reachable in every grid view's dropdown + palette.
- refactor: −~1,600 lines dead code (17 orphaned files) + 5 invalid alert-bg CSS fixed.
- polish: WCAG-AA contrast, global :focus-visible ring, mobile-safe popup widths.
- refactor: Add-widget palette sources the full created-registry (add anything anywhere).
- refactor: UI primitives consolidated onto Panel base (empty/loading/sparklines/
  formatters/tabs/modals/iframes) + clickable-row a11y.
- refactor: SearchController extracted from the App.ts god-object (5713→5259 lines).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-22 18:07:47 -07:00
zeekayandClaude Opus 4.8 dbba6d26c0 refactor(world): extract SearchController from App.ts
Move the ⌘K search surface out of the App god-object into its own
controller: modal creation + per-variant source registration, result
routing (map fly-to / panel scroll / country brief), the live search
index, and the Cmd/Ctrl+K keydown listener. App composes it with lazy
accessors (getMap/getMapLayers/getPanels/getAllNews/getLatest*) so live
field reassignments read fresh — behavior byte-for-byte identical.

App.ts 5686 -> 5215 lines; 7 methods + 2 fields relocated; 16 now-unused
config imports pruned. Build + variant-switch-inplace + stations-wall e2e
green.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-22 17:57:36 -07:00
zeekayandClaude Opus 4.8 d23e1126fd refactor(world): BaseModal + embedIframe unification + clickable-row a11y
BaseModal: one base owning full-screen overlay behavior — .active/create
lifecycle via mountOverlay/unmountOverlay, backdrop-click + Escape close, and a
basic focus trap (focus first focusable on open, restore on close). SignalModal,
StoryModal, SearchModal and MobileWarningModal migrate onto it with no change to
their DOM or public methods; this adds Escape-to-close to the three that lacked
it.

embedIframe({src,title,allow,allowFullscreen,...}) in utils/embed.ts: one
canonical player-iframe factory with the agreed YouTube allow + sandbox. The
four raw embeds (LiveWebcamsPanel, LiveNewsPanel desktop bridge, WatchQueuePanel,
services/immersive) route through it.

Clickable-row a11y: utils/a11y.ts makeActivatable() gives click-handled rows
role=button + tabindex=0 + Enter/Space firing the same action — applied to
Displacement, Ucdp, Climate, Investments and StrategicRisk rows. SearchModal
result rows get role=button (already keyboard-operable via its input model).
MarketPanel context-menu rows get makeContextMenuActivatable() so the copy menu
is reachable by keyboard. NewsPanel and TechReadiness skipped (see report).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-22 17:10:56 -07:00
zeekayandClaude Opus 4.8 2e0e820285 refactor(world): consolidate UI primitives onto Panel base (empty/loading/sparklines/formatters/tabs)
Behavior-neutral decomplect (same rendered output, fewer implementations):
- Panel.showEmpty() added; ~15 hand-rolled empty states (4 divergent class conventions
  .empty-state/.panel-empty/.cloud-empty/.wq-empty) routed through the one base method.
- Loading spinner clones → this.showLoading() (the base radar).
- Live-dot / freshness → the base setDataBadge; duplicate keyframes/classes unified.
- 5 sparkline generators → one sparkline() in utils/market-format.
- ~7 local number/pct/currency reimplementations → the shared utils/cloud-format helpers.
- Several hand-rolled tab-switchers → Panel.renderTabs + one .panel-tab convention.

Build + globe/panel e2e green (variant-switch passes uncontended). Partial pass — the
tab migration (EconomicPanel), BaseModal/embedIframe unification, and clickable-row a11y
are still to land in a follow-up.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-22 16:37:36 -07:00
zeekayandClaude Opus 4.8 59d6212836 refactor(world): Add-widget palette sources the full created-registry (audit §3)
renderAddWidgetGrid iterated only this.panelSettings — the CURRENT variant's subset —
so ~20 instantiated panels (service-status, lux-book, startups, accelerators, …) were
unreachable from the palette even though its own comment calls it "a searchable palette
over the panel registry." Now it sources from Object.keys(this.panels) (every panel that
exists this session) ∪ panelSettings; display names resolve via the new canonical
PANEL_NAMES union (+ i18n); panelSettings is purely the enabled overlay. setPanelEnabled
now creates the overlay entry on demand, so a palette-added non-variant panel actually
shows. renderPanelToggles stays the curated per-view quick-toggle (no 40-item wall).
Result: any panel is addable from any view; registry and menu can no longer disagree.

Build + globe/panel e2e green.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-22 14:13:36 -07:00
zeekayandClaude Opus 4.8 b46e28e730 polish(world): a11y + contrast + mobile-safe popups (audit §6)
- Contrast: dark-theme --text-muted #666 (~2.8:1) → #949494, --text-faint #555
  (~2.0:1) → #8a8a8a — both now pass WCAG AA on the dark surface (muted text is
  used ~99×). Light theme untouched.
- Keyboard focus: add a global, theme-aware :focus-visible ring (2px var(--accent))
  on links/buttons/inputs/[role=button|tab]/[tabindex] — shows on tab-focus only,
  never on mouse; low specificity so per-component focus styles still win.
- Mobile: viewport-cap the fixed-width flyout/map-popup/dropdown widths
  (width: min(360|380px, calc(100vw - 24px))) so they stop overflowing narrow
  phones — identical on desktop, only shrinks below the cap.

CSS-only, behavior-neutral; build green.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-22 13:49:40 -07:00
zeekayandClaude Opus 4.8 94b32fcf5a refactor(world): remove ~1.6k lines of dead code + fix 5 invalid alert backgrounds
Grep-verified dead (zero instantiations, zero non-barrel imports) — deleted end to end:
- 11 orphaned panel components never built in createPanels/ensureVariantPanels:
  Robotics/Quantum/PostQuantum/Weather/Sports/SpaceWeather (the "domain-lens" set),
  plus Regulation (superseded by the NewsPanel 'regulation' feed), GeoHubs, TechHubs,
  VerificationChecklist, and CountryIntelModal (superseded by CountryBriefPage).
- 5 dead backing services (robotics/quantum/post-quantum/space-weather/sports) +
  config/post-quantum.ts (only importer was the dead service).
- The 6 dead domain-lens panel keys in FULL_PANELS — they rendered Panels-menu toggles
  and Add-widget entries that did NOTHING (applyPanelSettings/getElement hit undefined),
  and their now-orphaned `.domain-*` CSS block.
- Barrel exports for the deleted panels.

KEPT (verified live, NOT panels): config/quantum.ts + config/robotics.ts + services/
weather.ts (map layers), and services/geo-hub-index + tech-hub-index (feed the map's
geo/tech activity markers via geo-activity/tech-activity — the audit missed the relative
import; the build caught it before commit). Relocated `StockIndexData` into its only
consumer, CountryBriefPage.

CSS: `rgba(var(--semantic-critical), 0.2)` is invalid (the token is a hex, not an r,g,b
triple) so 5 alert-fill backgrounds silently rendered nothing → `color-mix(in srgb,
var(--semantic-critical) 20%, transparent)`.

Build + globe/panel e2e green. No feature loss.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-22 13:47:06 -07:00
zeekayandClaude Opus 4.8 0430150f33 fix(world): free the parked mapbox GPU context in backgrounded tabs (Safari crash)
Symptom: with several world.hanzo.ai tabs open in Safari, the page hits "a problem
occurred" and reloads to a BLACK globe. Root cause: each tab holds TWO WebGL contexts
— the visible native GlobeView (deck.gl) AND a PARKED mapbox (frozen in mercator behind
it, still holding a full GPU context). Several tabs × 2 contexts overruns Safari's
GPU/context ceiling → crash-reload → the fresh page's context is gone → black.

Fix: after a tab has been hidden MAP_GPU_PARK_MS (30s), drop the parked mapbox's WebGL
context via WEBGL_lose_context; restore it the instant the tab is shown. Reuses the
existing webglcontextlost/restored handlers (mapbox-gl + deck.gl both rebuild their GPU
resources on restore) — nothing is re-wired, and the visible GlobeView's own context is
untouched. Worst case (a restore that doesn't repaint) equals today's post-crash state
but with one fewer live context — strictly less GPU pressure, never worse.

DeckGLMap.parkGPU/restoreGPU + MapContainer delegation + App visibilitychange timer
(cleared on show and on destroy). Existing globe/map + panel e2e green (no regression).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-22 13:24:28 -07:00
zeekayandClaude Opus 4.8 3c6bc9c92a fix(world): surface News Wall + Markets Bubble in every grid view's dropdown
Both were reachable in almost no view: News Wall existed only in the World variant,
Markets Bubble only in World (opt-in) + Finance — and Finance renders the terminal
(no panel grid), shadowing it there. Since renderPanelToggles AND the "+ Add widget"
palette only iterate the CURRENT variant's panelSettings, on the default Cloud view
(and AI/Crypto/Tech) both panels were invisible and unreachable.

Add both (opt-in, enabled:false) to CLOUD/AI/CRYPTO/TECH so they show in every grid
view's Panels dropdown + Add-widget palette. e2e-reprobed present in all four.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-22 09:49:23 -07:00
hanzo-dev 4c8ecaebfe release: v2.4.45 — Live Webcams verified-live default grid + e2e drift fixes (runtime-fetch, keyword-spike)
- Live Webcams: replace dead default feeds with browser-verified-live 24/7 streams
  (grid = Kyiv/Tel-Aviv/Miami/Taipei, each confirmed PLAYING/isLive this session via
  the YouTube IFrame API in a full-codec browser); prune 6 non-embeddable feeds, add
  Monterey Bay. Fixes the "mostly not available" webcam panel.
- e2e (dev-only, no bundle impact): runtime-fetch fallback test → current /v1/world/*
  paths + real host discriminator; keyword-spike test → mid-sentence proper-noun +
  initI18n + opt-in badge flag; drop the obsolete arch-download test (feature removed
  for Hanzo); loadMarkets test → yahoo-batch envelope, no commodities. 7/7 pass.

Also carries the analyst-dock hzc-* selector drift fixes (19/19 pass).

Claude-Session: https://claude.ai/code/session_01NP4ehjWW2h98FkE4YjUKuJ
2026-07-22 04:55:52 -07:00
hanzo-dev 5f8dcec1c9 test(e2e): fix analyst-dock selector drift (hzc-*) + enso/country-view/video-drag drift
The analyst copilot redesign (5dc7232c) renamed .ai-dock-*/.ai-analyst-* → .hzc-*;
tests still clicked the dead .ai-dock-fab and timed out. Remap all dock selectors
and update assertions to the redesigned behavior (model picker is now a popover not
a <select>; tool-trace <details> opens by default; db glyph not 🔧 emoji). Also:
enso-training/real-models are cloud-variant panels (navigate ?variant=cloud) showing
the real served-model catalog not opaque arm-N; country-view waits for
__app.countryBriefPage; video-drag uses an on-screen drop coordinate + map-led reset
order. 19/19 pass across the touched specs. Golden-screenshot/headless-WebGL specs
left untouched (CI-env goldens). Dev-only; CI does not gate on e2e.

Claude-Session: https://claude.ai/code/session_01NP4ehjWW2h98FkE4YjUKuJ
2026-07-22 04:36:06 -07:00
hanzo-dev 021af33835 release: v2.4.44 — fix china-macro 24s hang, AI Insights infinite spinner, finance dark skeletons, dead webcams, Sentry 403
Visual + runtime review across all variants surfaced five real defects; fixed each honestly (no fabricated data):
- china-macro: 10s frontend AbortController + 9s backend deadline + negativeTTL negative-cache — was hanging ~24s then aborting (cloud-3d/crypto/fund).
- AI Insights: summarization.ts raw fetches → fetchWithTimeout (root cause) + 12s render-race + briefSettled guard — panel no longer spins forever for signed-out users.
- finance: dark .fin-tv-skeleton overlay over TradingView iframes (removed on load) — no more white boxes on the dark theme during load.
- Live Webcams: prune 3 dead upstream feeds (Jerusalem/Tehran/DC), repoint default grid to working streams (Kyiv/Mecca/London/NY).
- Sentry: drop the stale hardcoded DSN, env-gate on VITE_SENTRY_DSN (no-op when unset) — kills 403 spam on every variant (was already over-quota/broken).

Claude-Session: https://claude.ai/code/session_01NP4ehjWW2h98FkE4YjUKuJ
2026-07-22 03:38:15 -07:00
hanzo-dev 689488c2e3 release: v2.4.43 — News Wall, Christie's alt-assets, Markets Bubble, org dashboard, yahoo daily series (version label corrected)
Rolls up the 7 commits on main since v2.4.42 (News Wall, Christie's +
LuxuryEstate alt-asset feeds, Markets Bubble D3 circle-pack, org-shared
editable dashboard default, yahoo range/interval daily series). package.json
had regressed to 2.4.39 from a merge-conflict resolution while the highest
tag was already v2.4.42 and prod ran this same code — correct it forward to
2.4.43 so the deployed image, bundle version and /v1/world/version agree.

Claude-Session: https://claude.ai/code/session_01NP4ehjWW2h98FkE4YjUKuJ
2026-07-22 02:44:20 -07:00
Hanzo Dev b9a5ac383f fix(world): resolve package.json merge-conflict markers (broke npm ci)
The v2.4.39 rebase left conflict markers in package.json — npm ci failed the
image build. Keep version 2.4.39.
2026-07-22 00:49:06 -07:00
Hanzo Dev e55e59661b release: v2.4.39 — yahoo range/interval forwarding (daily series) 2026-07-22 00:46:44 -07:00
Hanzo Dev f6b60c159e feat(world/yahoo): forward validated range+interval → daily series
handleYahooFinance now forwards a whitelisted range/interval to Yahoo's chart
endpoint (e.g. range=1y&interval=1d) instead of always returning the intraday
default. Absent params preserve prior behavior; the range keys the cache so
different spans of a symbol cache separately. Unlocks real 1-year daily
growth charts on lux.fund (was intraday-only).
2026-07-22 00:46:31 -07:00
zeekayandClaude Opus 4.8 0e3dce34b7 feat(world): org-shared editable dashboard default
Promote the world.hanzo.ai dashboard from per-user-only to an org/shared
scope: a hanzo.app org admin publishes a shared layout/feeds/channels that
every member of the org hydrates as their default, with per-user changes
still overriding it.

Backend (internal/world):
- New org-scoped dashboard doc keyed by owner via store.SharedSub (the same
  Settings table, 'dashboard' namespace, no new table). Served at
  /v1/world/dashboard/shared: GET returns the org default to any signed-in
  member; PUT publishes it and is admin-ONLY (403 otherwise).
- Admin gate reuses isAdminOrg + a new isOrgAdmin honoring IAM's isAdmin
  claim; writes are always scoped to the caller's own org (no cross-org).
- Decomplect: per-user and shared paths share ONE GET/PUT body
  (serveIdentityBlob), parameterized by the resolved identity/scope.

Frontend (src):
- dashboard.ts hydrate is scope-parameterized: the org default is applied
  first as the base, then the user's own doc overlays it (user wins). Both
  scopes fetched concurrently so boot stays bounded.
- publishOrgDashboard() PUTs the current layout to the shared endpoint
  (admin-only server-side). An admin-gated "Publish as org default" entry in
  the workspace context menu triggers it; anonymous/non-org unchanged.

Tests: Go handler tests cover admin publish + round-trip, non-admin 403,
member read, per-user isolation, cross-org isolation, anon 401, and the
admin-org publish path. Playwright specs cover shared-then-user precedence.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-22 00:04:32 -07:00
zeekayandClaude Opus 4.8 34829c48a9 feat(world): Markets Bubble — whole market universe as one D3 circle-pack
Add a live "all markets in one bubble" panel: every asset class at once as a
D3 circle-pack — a cluster per class (equities/commodities/FX/rates/crypto),
a leaf circle per instrument, radius ∝ importance × how hard it's moving, fill
= diverging green(↑)→red(↓) by percent move (flipped for vol gauges VIX/MOVE).
Hover any bubble for name / price / signed %. Smooth radius+colour tween on the
~30s jittered live poll (circles reused by id), lazy-built on first visibility
and paused off-screen, re-packed on resize.

Decomplected around ONE source of truth:
- config/market-universe.ts: every tradable declared once (class, group,
  weight, digits, inverse) with universeByClass / universeGroups / yahooUniverse.
- services/market-universe.ts: joins the Yahoo + CoinGecko boundary fetchers back
  to that metadata into one flat, plot-ready MarketDatum[] (in-flight-guarded,
  short-cached).
- CommoditiesPanel now DERIVES its symbol list from the universe (no inline
  duplication). FX/Rates panels left untouched — they carry extra EM/curve symbols
  and bespoke rendering the universe doesn't model, so migrating them would risk
  working behaviour.

Wired into FINANCE_PANELS (on) and FULL_PANELS (opt-in). e2e mocks the market
endpoints and asserts bubbles across ≥3 classes, tooltip-on-hover with a %, and
survival across a live re-poll.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-21 23:45:18 -07:00
zeekayandClaude Opus 4.8 a7e4c5bd48 feat(world/finance): live alt-asset feeds — Christie's auctions + LuxuryEstate
The finance-terminal "Art & Collectibles — Auctions" and "Luxury Real Estate"
panels rendered "live feed pending" because /v1/world/auctions and
/v1/world/luxury-realestate did not exist. Make them return REAL data.

world-gw (cmd/world) now scrapes two public sources server-side, hourly, and
serves every caller from the SWR cache:

- GET /v1/world/auctions — Christie's public results: realized SALE totals
  (title, inferred category, price+currency, date, location, image, link).
  Sotheby's (the requested headline) gates realized prices behind a login —
  its /data/api/asset.saleresults.json returns 401 "Not signed in" — so the
  honest public major house is Christie's, exactly the fallback the panel names.

- GET /v1/world/luxury-realestate — LuxuryEstate.com listings (title, location,
  price+currency, type, image, link). JamesEdition (the requested headline)
  sits behind a Cloudflare JS challenge that blocks datacenter egress, so it
  cannot be fetched from the DOKS pod; LuxuryEstate is reachable and
  robots-permitted.

Robust + respectful + honest:
- One request per source per hour (StartAltAssets warmer + 1h TTL / 24h stale).
- On a source failure: serve last-good; with no cache yet, honest empty
  {items:[]} (panel shows "live feed pending"). NEVER fabricated data.
- Payload is exactly {items: AltFeedItem[]} — matches AltFeedPanel.ts
  field-for-field. Source attributed per feed; SPA source labels updated to the
  real sources (Christie's / LuxuryEstate).
- Parsers extract the embedded results JSON (robust to DOM churn); unit-tested
  against real trimmed captures in testdata/.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-21 22:05:09 -07:00
zeekayandClaude Opus 4.8 f3eaddb132 feat(world): News Wall — all live news channels at once, hover a tile for audio
New opt-in "News Wall" panel: every live news channel plays at once in a responsive
grid of controllable YouTube players (all muted). HOVER a tile → it gets audio focus
(unmuted, others muted); leaving the wall mutes everything. The wall + its N players
build lazily on first visibility and pause when it scrolls off (IntersectionObserver),
so it costs nothing until toggled on and never decodes a hidden wall.

Decomplect — one source each, shared with LiveNewsPanel, no duplication:
- src/services/youtube.ts: the ONE YouTube IFrame API loader + player types (was
  braided into LiveNewsPanel; the grid embedder needed the same loader).
- src/config/live-channels.ts: the ONE channel list + liveChannels()/getDefaultLiveChannel()
  (data belongs in config, not a panel). LiveNewsPanel + immersive now import it.

Wiring: registered in createPanels, exported from the barrel, added to FULL_PANELS
(enabled:false — opt-in like Live Webcams), styled (.stations-* responsive auto-fill
grid with a live focus ring).

e2e (stations-wall.spec.ts): one tile per channel; hover moves the single audio focus;
leaving mutes all. variant-switch-inplace still green (the LiveNewsPanel refactor is
behavior-preserving).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-21 21:54:24 -07:00
Claude 00d2fa13be fix(world/finance): mount the terminal on the in-place variant switch too
The finance terminal only mounted on the initial ?variant=finance load; the new
in-place variant switch (setSiteVariant, no reload) ran ensureVariantPanels but
never mounted the terminal — so clicking the Finance tab showed the panel
dashboard, not the Bloomberg terminal. Replace mountFinanceTerminal with
syncFinanceTerminal(variant): mounts the terminal overlay when entering finance,
removes it (destroy) when leaving. Wired into both init() and the switch. Now the
tab works both ways — verified: in-place switch mounts 27 chart cards.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-21 21:10:01 -07:00
Claudeandzeekay d9cf19f195 feat(world/globe): real-time global flow layer — animated comms + trade arcs
A toggleable "Global flows" map layer draws the real backbone of world comms +
trade as animated great-circle arcs on the globe ("where everything is going"):
the principal submarine-cable / internet-backbone routes (cyan) and the major
maritime trade + energy lanes (amber), between real global hubs. Direction is
source → target with a travelling pulse (the existing AnimatedArcLayer + the
shared cloud-pulse clock).

- New src/config/global-flows.ts: 27 real corridors (NYC↔London transatlantic,
  Tokyo↔LA transpacific, Shanghai↔Rotterdam via Suez, Gulf oil → Asia, etc.),
  weighted by representative magnitude — no synthetic data.
- createGlobalFlowsLayer() in DeckGLMap: great-circle on the globe, alpha + width
  scale with corridor weight, comms/trade colour-coded.
- Low-end degradation (device tier): draws only the heaviest maxFlowArcs()
  corridors (40 low / 120 mid / 300 high) so a weak GPU never stalls.
- Toggle: "Global flows" in the layer panel (world + tech variants) + URL state;
  default OFF (overlay). Animated only while visible (cloud-pulse gate).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-21 20:58:49 -07:00
zeekayandClaude Opus 4.8 5d44059623 perf(world): switch view variants in place — kill the tab-switch freeze
Switching the header variant tabs [Cloud|AI|Crypto|Finance|Tech|World] did a full
window.location reload: it re-created the deck.gl WebGL context, respawned the ML
workers, re-mounted every video iframe and cold-refetched ~48 endpoints. That
cold-start churn WAS the freeze.

SITE_VARIANT is now runtime-mutable (getSiteVariant / setSiteVariantRuntime).
setSiteVariant switches IN PLACE — recompute the target's config and re-apply
panels/map-layers/feeds/tabs/URL live (history.replaceState), exactly like the
intra-variant panel toggle. Heavy singletons stay warm; the synchronous switch
cost is a few ms (e2e-gated <500ms via window.__switchT) vs a full reload.

Also:
- Header controls (H-toggle switcher, search, settings, reset) are wired BEFORE
  `await this.mapReady` so the shell is interactive on first paint — they were
  dead until the 2.7 MB map chunk loaded (a cold H-toggle click was lost).
- LiveNewsPanel pauses its YouTube player via IntersectionObserver when off-screen
  (matching LiveWebcamsPanel) — no decoding a hidden news embed.
- device-tier: adaptive DPR/refresh/flow-arc knobs on low-end hardware.
- Sentry deferred off the critical path (code-split, after first paint).

e2e: variant-switch-inplace.spec.ts proves no reload (JS-context sentinel + same
deck.gl canvas survive), panel-set swap, URL/active-tab re-point, across 3 switches.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-21 20:56:45 -07:00
Claude 35b8c4bfcf feat(world/finance): Bloomberg-style finance terminal (charts ≫ news), lazy-loaded
The finance variant now renders a dense, dark terminal of LIVE TradingView
charts instead of the news-heavy map dashboard — global indices (S&P 500,
Nasdaq 100, Dow, VIX, Nikkei, Hang Seng, China A50, DAX, FTSE, CAC, Euro Stoxx,
ASX), commodities (gold, silver, WTI, Brent, nat gas, copper), forex (DXY +
majors) and crypto (BTC/ETH) — plus a live ticker tape, a market-overview
widget, a Lux DEX trade card and alt-asset feeds (auctions + luxury real estate).

- Code-split: App dynamic-imports FinanceTerminal only for the finance variant,
  so the TradingView embed logic + terminal CSS never ship in the entry bundle.
- Low-end degradation (device tier): fewer charts + lighter widget types
  (mini-symbol-overview instead of advanced-chart), and every widget lazy-mounts
  on viewport (IntersectionObserver) so a weak laptop never mounts dozens of
  live embeds at once. The map is skipped entirely for this variant (−2.7 MB).
- CAPITALCOM CFD index symbols (free-embed compatible); exchange-native SP:SPX /
  DJ:DJI etc. are account-gated and render an error card.
- Lux DEX: exchange.lux.network sets X-Frame-Options: DENY, so the SwapWidget
  can't be iframed — surfaced as a prominent launch card to the real DEX.
- Auctions (Sotheby's) + luxury RE (JamesEdition) fetch /v1/world/{auctions,
  luxury-realestate}; until that backend feed exists they show an honest
  "live feed pending" state — never fabricated listings.
- CSP (index.html): allow s3/s.tradingview.com (script) + the tradingview-widget
  frames. The production HANZO_STATIC_CSP is widened in the universe world CR.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-21 20:45:22 -07:00
Claudeandzeekay e651ccac16 perf(world): defer the map off the first-paint critical path (eager JS 4.1MB→1.4MB)
On a low-end laptop the globe (mapbox-gl + deck.gl ≈ 2.7 MB) was eagerly
modulepreloaded and constructed synchronously during renderLayout, so nothing
painted until ~4.1 MB of JS downloaded + parsed + WebGL initialised (≈17.7 s to
app-shell under 6× CPU throttle on the live build). This code-splits the map and
degrades gracefully for weak hardware.

- Map loads via dynamic import() in App.mountMap(); init awaits it AFTER the
  shell paints, before map-dependent wiring. Every this.map access was already
  null-guarded, and mountMap replays the initEscalation / onTimeRangeChanged /
  initial-URL-state wiring that used to run inline in createPanels.
- Isolate Vite's __vitePreload helper into its own chunk (manualChunks): it had
  been co-located INTO the 'map' chunk, so the entry statically imported 'map'
  just for that helper and Vite modulepreloaded the whole 2.7 MB before paint —
  THE reason the map stayed eager despite the dynamic import.
- @/components barrel re-exports the map classes as TYPES only (their modules
  have top-level side effects; value re-exports also dragged 'map' eager).
- Defer @sentry/browser (~460 KB raw / ~130 KB gzip) out of the entry to
  requestIdleCallback, with a synchronous early-error buffer replayed on init so
  no boot error is lost.
- New device-tier util (hardwareConcurrency + deviceMemory + reduced-motion,
  ?tier= override): low-end drops the deck.gl overlay DPR 2→1, skips the idle
  globe auto-spin, and scales real-time refresh intervals up to 2×.

Eager JS before first paint: 4136 KB → 1440 KB (raw). Map (2.7 MB) + Sentry now
load after first paint.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-21 20:30:04 -07:00
Hanzo Dev 4e0bd1d09b release: v2.4.38 — SuperAdmin fleet view (clusters + GPU queue) + red-review fixes
Ships the fleet view (DOKS cluster nodes + gpu-jobs queue panels, gated by the
canonical SuperAdmin predicate) and the red-review fixes (adminOrgs base =
{admin,built-in} with operator org via WORLD_ADMIN_ORGS; no-store on all admin
Cloud reads). Pairs with universe WORLD_ADMIN_ORGS=hanzo.
2026-07-21 17:33:24 -07:00
Hanzo Dev be92f4ad5f world: address red review — canonical SuperAdmin predicate + no-store admin reads
1. adminOrgs() base is now EXACTLY {admin, built-in} — cloud's canonical
   globalAdminOrgs / principal.IsSuperAdmin, ONE source of truth. The operator
   org is additive-only via WORLD_ADMIN_ORGS (deploy env), never hardcoded; an
   unset/empty env resolves to the base, never empty and never everyone. Tests
   that exercise the operator (owner "hanzo") now declare WORLD_ADMIN_ORGS=hanzo,
   proving the env-driven path; the red matrix gains an operator-via-env case and
   an operator-without-env→403 case; isAdminOrg-exact denies "hanzo" by default.

2. Every admin Cloud read returns Cache-Control: private, no-store (clusters,
   queue, and the family: fleet/services/analytics/llm) so a caller's own
   admin-scoped aggregate is never stored in a shared cache. Infra tests assert it.

WORLD_ADMIN_ORGS=hanzo is set on the world App CR (universe) so the operator
keeps dashboard access via config, not code.
2026-07-21 17:25:33 -07:00
Hanzo Dev 0352c8ed9c merge(worktree-agent-ac3cbb0aaa6f4c669): consolidate onto main 2026-07-21 17:19:13 -07:00
Hanzo Dev 3bb246549c merge(feat/superadmin-fleet-view): consolidate onto main 2026-07-21 17:19:13 -07:00
Hanzo Dev 6737150a9a merge(chore/fork-hygiene-notice): consolidate onto main 2026-07-21 17:19:13 -07:00
Hanzo Dev 4f699c714b release: v2.4.37 — insider ISO-8859-1 fix + alt-data plane live
Aligns package.json + VERSION to the release (both had drifted). This is the
first deployable release carrying the four /v1/world/* macro signals with the
insider charset fix proven against the live SEC feed.
2026-07-21 17:18:44 -07:00
Hanzo Dev 513de4b1e2 fix(world/insider): decode the ISO-8859-1 SEC feed (was silently empty)
The live EDGAR getcurrent atom feed is served as ISO-8859-1, not UTF-8, so
plain xml.Unmarshal errored ('encoding "ISO-8859-1" declared but
Decoder.CharsetReader is nil') and parseEdgarAtom returned zero entries — the
endpoint answered 200 with count:0 in prod. Give the decoder a CharsetReader
(golang.org/x/net/html/charset) so any declared charset decodes. The unit test
now uses an ISO-8859-1 fixture with a Latin-1 byte as a regression guard.
Proven against the live feed: 100 entries decode cleanly.
2026-07-21 17:14:46 -07:00
Hanzo Dev e198d9cd47 world: SuperAdmin fleet view — DOKS cluster nodes + GPU job queue
Add the two admin-only Cloud panels the fleet view was missing, both gated by
the existing requireAdmin predicate (owner in the admin org) and both aggregating
real cloud subsystems with the caller's own bearer:

- /v1/world/cloud/clusters: DOKS + BYO clusters grouped by cluster (hanzo-k8s,
  adnexus-k8s, …) from the unified k8s noun (/v1/k8s/clusters + per-cluster
  detail), each with node pools, per-node status and GPU capacity. ClusterPanel
  renders it; honest ready/total counts, never fabricated nodes.

- /v1/world/cloud/queue: the gpu-jobs queue from the tasks engine
  (/v1/tasks/namespaces/gpu-jobs/activities) fused with /v1/fleet/workers —
  depth by status, running + pending jobs each with the dispatching service
  (the job type's prefix), the claiming worker, and the target model, plus the
  online worker count. QueuePanel renders it.

Also surface what each BYO GPU is serving on the existing fleet panel: fold the
worker's advertised hanzo-engine models (engine.serve) + engine status + job
queue into /v1/world/cloud/fleet and the worker row.

Tests: hermetic gate (401 anon / 403 non-admin, no payload leak) + admin
success-path reshaping the real upstream shapes; a playwright e2e that renders
both panels for an admin owner and confirms a non-admin owner is denied (panels
never mount).
2026-07-21 17:00:19 -07:00
hanzo-dev f4e5c7c6f3 router: enso fold reads router/{ledger,rewards} (routes renamed from export-routing-*) 2026-07-21 16:57:13 -07:00
Hanzo Dev 0077dfd590 release: v2.4.36 — alt-data plane (insider · defi · layoffs · congress)
Four macro signal sources under /v1/world/*:
  insider  — SEC EDGAR Form 4 filing flow (keyless, live)
  defi     — DeFiLlama TVL + stablecoin float (keyless, live)
  layoffs  — Texas WARN notices (keyless, live)
  congress — Quiver Quant congressional trades (QUIVER_API_KEY, degrades clean)
Bumps the vestigial VERSION file to the release for the first time in a while.
2026-07-21 16:54:10 -07:00
Hanzo Dev 81cd2e1455 feat(world): alt-data plane — /v1/world/layoffs (TX WARN) + /v1/world/congress (Quiver, key-gated)
Completes the four macro signal sources on the alt-data plane:
  insider (SEC Form 4), defi (DeFiLlama), layoffs (TX WARN), congress (Quiver).
layoffs reads the Texas Workforce Commission WARN Socrata feed (keyless, live).
congress reads Quiver Quant when QUIVER_API_KEY is set and degrades to a clean
unavailable payload otherwise — the free house/senate feeds are all gated now,
so this follows the same key-gated pattern as finnhub/eia/acled. Pure parsers
are unit-tested; the route sweep confirms both degrade without 5xx or HTML leak.
2026-07-21 16:53:54 -07:00
Hanzo Dev f24786e5a7 feat(world): alt-data plane — /v1/world/defi (DeFiLlama) + /v1/world/insider (SEC Form 4)
DeFi snapshot: total TVL across chains, top-10 chains by share, stablecoin
float (top-6 by market cap) — one cached pull shared by all viewers.

Insider flow: SEC EDGAR Form 4 filing stream (buys/sells by officers,
directors, 10% holders) — the filing-velocity macro signal. SEC-compliant
descriptive User-Agent; pure atom parsing unit-tested.

Both cached, single-flighted via cachedJSON, degrade to a clean 200
unavailable payload. Routes swept by TestRoutesRespond.
2026-07-21 16:53:54 -07:00
Hanzo Dev 63cae5c2d2 world: autonomous multi-asset fund brain — paper-only book + engine
Port the fund conviction model (research/rotation_model.py §5) to Go, layered
on the existing RRG kernel (handlers_rotation.go): each asset-class sleeve is an
equal-weight synthetic scored on the RS-Ratio/RS-Momentum plane vs SPY, and
conviction = quadrant base + momentum tilt + oversold bonus, normalized into a
preferred portfolio.

- fund_model.go   sleeves, scoreSleeves, conviction, overallStance (pure)
- fund_broker.go  the PAPER/LIVE seam: Broker iface, PaperBroker (only real
                  impl), LiveBroker stub that refuses; append-only paper ledger
- fund_engine.go  autonomous loop: recompute book, diffOrders, execute paper,
                  fold fills, mark sim PnL
- handlers_fund.go /v1/world/fund, /fund/ledger, /fund/brief

No real order is ever placed: the engine holds a Broker whose sole real
implementation is PaperBroker; live execution has no wiring.
2026-07-21 16:14:44 -07:00
Hanzo Dev 5f0857d8f8 chore(notice): pin upstream provenance and deviations; sweep product branding 2026-07-13 16:12:09 -07:00
356 changed files with 63090 additions and 5548 deletions
+16
View File
@@ -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)
+44
View File
@@ -0,0 +1,44 @@
name: Sync to git.hanzo.ai
# hanzoai/world is NATIVE on git.hanzo.ai (hanzoai/git) — canonical repo + where CI/CD
# runs: build/test/release from .hanzo/workflows/ on the Hanzo git-runner. GitHub is the
# DOWNSTREAM MIRROR. The mirror App already syncs on push; this is the explicit,
# self-documenting belt-and-suspenders — its presence in .github/ signals
# "this repo migrated to git.hanzo.ai."
#
# IDEMPOTENT: mirror-sync just tells Gitea to pull HEAD, so N nudges == 1 (same latest
# state); concurrency coalesces a burst of pushes to a single in-flight sync. FAST: no
# checkout, one bounded curl, hard timeouts. FAIL-SOFT: a missing token or hiccup never
# fails the push (the App webhook still mirrors). Set HANZO_GIT_TOKEN to enable the nudge.
on:
push:
branches: [main]
workflow_dispatch:
# Only the newest push's sync needs to run — cancel any older in-flight one (it would
# pull a now-stale HEAD). This is what makes rapid pushes idempotent + cheap.
concurrency:
group: sync-git-hanzo-ai
cancel-in-progress: true
jobs:
sync:
runs-on: ubuntu-latest
timeout-minutes: 2
steps:
- name: Nudge git.hanzo.ai to pull HEAD (mirror-sync)
continue-on-error: true
env:
HANZO_GIT_TOKEN: ${{ secrets.HANZO_GIT_TOKEN }}
run: |
set -u
if [ -z "${HANZO_GIT_TOKEN:-}" ]; then
echo "HANZO_GIT_TOKEN not set — relying on the mirror App webhook. Skipping nudge."
exit 0
fi
if curl -fsS --max-time 20 --retry 2 --retry-delay 2 -X POST \
-H "Authorization: token ${HANZO_GIT_TOKEN}" \
"https://git.hanzo.ai/api/v1/repos/hanzoai/world/mirror-sync"; then
echo "git.hanzo.ai mirror-sync triggered"
else
echo "mirror-sync nudge failed (non-fatal) — the App webhook still mirrors"
fi
+1
View File
@@ -2,6 +2,7 @@ node_modules/
.idea/
.planning/
dist/
dist-react/
.DS_Store
*.log
.env
+116
View File
@@ -0,0 +1,116 @@
name: Release
# Cuts a release of ghcr.io/hanzoai/world — the static SPA frontend image (Vite ->
# hanzoai/static) the operator CR pins. Mirrors hanzoai/app/release.yml:
# a git tag v<X.Y.Z> ⇔ an image ghcr.io/hanzoai/world:v<X.Y.Z>
# Builds on the git.hanzo.ai native runner (git-runner). ghcr auth via GH_PAT / user
# hanzo-dev.
#
# ADDITIVE MIGRATION NOTE: the legacy .github lane (docker-build.yml) does NOT cut
# v-tags — it only REACTS to `tags: ['v*']` to build+deploy. So there is no
# competing tag-cutter and no version runaway. paths-ignore below keeps this native
# release from re-firing on .github/** or doc-only edits.
on:
push:
branches: [main]
# Workflow/doc-only edits must NOT cut a release. Real code changes on main still
# trigger; use workflow_dispatch to force.
paths-ignore:
- '.github/**'
- '**.md'
workflow_dispatch:
permissions:
contents: write
packages: write
id-token: write
concurrency:
group: release-world
cancel-in-progress: false
jobs:
build-amd64:
runs-on: [hanzo-build-linux-amd64]
steps:
- name: Checkout (full history + tags — version floor read from tags)
uses: actions/checkout@v4
with:
fetch-depth: 0
fetch-tags: true
- name: Compute next version (monotonic patch bump over the 2.4.x deploy line)
id: ver
env:
GH_PAT: ${{ secrets.GH_PAT }}
run: |
set -euo pipefail
git fetch --tags --force --quiet
# The deployed lineage is 2.4.x: the newest-by-date tags on main are v2.4.48,
# v2.4.47, ... (HEAD is "release: v2.4.48") and every recent release commit is
# on it. Stray tags exist off that line — notably v2.9.32 (a July-9 relic,
# numerically largest but OLDER than the active 2.4 stream) and the 2.5.x
# cluster. `sort -V` is purely numeric, so an UNCONSTRAINED scan lets the
# v2.9.32 orphan hijack the floor and cut a v2.9.33 the deploy stream can't
# follow. Pin the scan to the 2.4. line so the bump stays monotonic over the
# ACTUAL deploy stream, not the numerically-largest orphan tag.
LINE='2.4.'
git_max="$(git tag -l "v${LINE}[0-9]*" \
| sed 's/^v//' | grep -E "^${LINE//./\\.}[0-9]+$" | sort -V | tail -1 || true)"
# Fold in ALREADY-PUSHED container tags on the same line, so a number that has
# an image (even from a run that died before git-tagging) is never reused.
# Best-effort — never fails the run if the API is down. Use curl (universally
# present) NOT gh. Fold BOTH v-prefixed and legacy non-v 2.9.x image tags.
cont_max=""
if [ -n "${GH_PAT:-}" ]; then
_pkgjson="$(curl -fsSL -H "Authorization: Bearer $GH_PAT" \
-H "Accept: application/vnd.github+json" \
'https://api.github.com/orgs/hanzoai/packages/container/world/versions?per_page=100' 2>/dev/null || true)"
cont_max="$(printf '%s' "$_pkgjson" \
| { jq -r '.[].metadata.container.tags[]?' 2>/dev/null || grep -oE '"v?[0-9]+\.[0-9]+\.[0-9]+"' | tr -d '"'; } \
| sed 's/^v//' | grep -E "^${LINE//./\\.}[0-9]+$" | sort -V | tail -1 || true)"
fi
max="$(printf '%s\n%s\n%s\n' "${LINE}0" "$git_max" "$cont_max" \
| grep -E "^${LINE//./\\.}[0-9]+$" | sort -V | tail -1)"
major="${max%%.*}"; rest="${max#*.}"; minor="${rest%%.*}"; patch="${rest##*.}"
version="${major}.${minor}.$((patch + 1))"
if git rev-parse -q --verify "refs/tags/v${version}" >/dev/null; then
echo "::error::computed v${version} already exists — aborting to avoid collision"; exit 1
fi
echo "version_v=v${version}" >> "$GITHUB_OUTPUT"
echo "Next release: v${version} (git_max='${git_max:-none}' container_max='${cont_max:-none}')"
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
with:
driver: docker-container
driver-opts: network=host
- name: Log in to ghcr.io (GH_PAT, write:packages)
uses: docker/login-action@v3
with:
registry: ghcr.io
username: hanzo-dev
password: ${{ secrets.GH_PAT }}
- name: Build + push ghcr.io/hanzoai/world
uses: docker/build-push-action@v6
with:
context: .
file: ./Dockerfile
platforms: linux/amd64
push: true
tags: ghcr.io/hanzoai/world:${{ steps.ver.outputs.version_v }}
provenance: false
- name: Verify the pushed image is pullable (guard the image ⇔ tag invariant)
# build-push-action can exit 0 while the manifest isn't yet resolvable at the
# registry. Prove the image resolves BEFORE creating the git tag, so a bad push
# fails the run instead of leaving a phantom tag → ImagePullBackOff.
run: |
set -euo pipefail
v="${{ steps.ver.outputs.version_v }}"
for i in 1 2 3 4 5 6; do
if docker manifest inspect "ghcr.io/hanzoai/world:${v}" >/dev/null 2>&1; then
echo "image ${v} is resolvable — safe to tag"; exit 0
fi
echo "ghcr manifest ${v} not visible yet (attempt ${i}/6) — retrying"; sleep 5
done
echo "::error::image ${v} not pullable after push — refusing to tag git (prevents ImagePullBackOff)"; exit 1
- name: Tag the release (image ⇔ tag invariant)
run: |
set -euo pipefail
git tag "${{ steps.ver.outputs.version_v }}"
git push origin "${{ steps.ver.outputs.version_v }}"
echo "Pushed image + tag ${{ steps.ver.outputs.version_v }} — bump the operator CR to deploy."
+9
View File
@@ -2,6 +2,15 @@
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
+6 -1
View File
@@ -35,6 +35,10 @@ COPY . .
ARG VITE_MAPBOX_TOKEN
ENV VITE_MAPBOX_TOKEN=$VITE_MAPBOX_TOKEN
RUN npm run build
# The @hanzo/gui (Tamagui) React rewrite, built to /app/dist-react as the opt-in
# canary surface. Served only to sessions that pass ?react (see cmd/world:
# canaryHandler); the vanilla dist above stays the default.
RUN npm run build:react
# ---- go stage: build the static server binary (CGO-free) -----------------
# go 1.26: go.mod requires >= 1.26.4 (github.com/hanzoai/sqlite drop-in). The
@@ -68,6 +72,7 @@ RUN apk add --no-cache ca-certificates tzdata \
&& adduser -D -H -u 10001 world
COPY --from=gobuild /out/world /usr/local/bin/world
COPY --from=web /app/dist /srv
COPY --from=web /app/dist-react /srv-react
USER world
EXPOSE 3000
ENTRYPOINT ["/usr/local/bin/world", "--root=/srv", "--addr=:3000"]
ENTRYPOINT ["/usr/local/bin/world", "--root=/srv", "--react-root=/srv-react", "--addr=:3000"]
+1 -1
View File
@@ -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
+25 -2
View File
@@ -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.
+14 -12
View File
@@ -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.
[![GitHub stars](https://img.shields.io/github/stars/koala73/worldmonitor?style=social)](https://github.com/koala73/worldmonitor/stargazers)
[![GitHub forks](https://img.shields.io/github/forks/koala73/worldmonitor?style=social)](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.
[![GitHub stars](https://img.shields.io/github/stars/hanzoai/world?style=social)](https://github.com/hanzoai/world/stargazers)
[![GitHub forks](https://img.shields.io/github/forks/hanzoai/world?style=social)](https://github.com/hanzoai/world/network/members)
[![License: MIT](https://img.shields.io/badge/License-MIT-green.svg)](https://opensource.org/licenses/MIT)
[![TypeScript](https://img.shields.io/badge/TypeScript-007ACC?style=flat&logo=typescript&logoColor=white)](https://www.typescriptlang.org/)
[![Last commit](https://img.shields.io/github/last-commit/koala73/worldmonitor)](https://github.com/koala73/worldmonitor/commits/main)
[![Latest release](https://img.shields.io/github/v/release/koala73/worldmonitor?style=flat)](https://github.com/koala73/worldmonitor/releases/latest)
[![Last commit](https://img.shields.io/github/last-commit/hanzoai/world)](https://github.com/hanzoai/world/commits/main)
[![Latest release](https://img.shields.io/github/v/release/hanzoai/world?style=flat)](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>&nbsp;
<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>&nbsp;
<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>&nbsp;
<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> &nbsp;·&nbsp;
<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>
![World Monitor Dashboard](new-world-monitor.png)
![Hanzo World Dashboard](new-world-monitor.png)
---
## 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).
---
+1 -1
View File
@@ -1 +1 @@
2.4.22
2.4.39
+94
View File
@@ -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"
]
}
+1
View File
@@ -0,0 +1 @@
export default new Map();
+1
View File
@@ -0,0 +1 @@
export default new Map();
+163
View File
@@ -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;
}
+2
View File
@@ -0,0 +1,2 @@
/// <reference types="astro/client" />
/// <reference path="content.d.ts" />
Binary file not shown.

After

Width:  |  Height:  |  Size: 16 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 13 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 13 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 14 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 14 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 14 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 13 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 14 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

+95 -8
View File
@@ -18,6 +18,7 @@ import (
"io/fs"
"log"
"net/http"
"net/url"
"os"
"os/signal"
"path/filepath"
@@ -30,8 +31,9 @@ import (
func main() {
var (
addr = flag.String("addr", envOr("WORLD_ADDR", ":"+envOr("PORT", "3000")), "listen address")
root = flag.String("root", envOr("WORLD_STATIC_ROOT", "dist"), "static SPA root directory")
addr = flag.String("addr", envOr("WORLD_ADDR", ":"+envOr("PORT", "3000")), "listen address")
root = flag.String("root", envOr("WORLD_STATIC_ROOT", "dist"), "static SPA root directory")
reactRoot = flag.String("react-root", envOr("WORLD_REACT_ROOT", "dist-react"), "canary React SPA root, served only to sessions that opted in via ?react")
)
flag.Parse()
@@ -49,13 +51,18 @@ func main() {
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
mux := http.NewServeMux()
srv.Mount(mux) // /v1/world/* routes
// Static SPA + fallback handles everything not matched by an /api route.
// gzipStatic wraps ONLY this handler — /v1/world/* keeps its streaming
// endpoints unbuffered.
mux.Handle("/", gzipStatic(newSPAHandler(*root)))
// The vanilla Vite build (--root) is the default surface; the React rewrite
// (--react-root) is served ONLY to a session that opted in via ?react, sticky
// per a first-party cookie — so shipping this changes nothing until we flip
// the default. gzipStatic wraps ONLY this handler — /v1/world/* keeps its
// streaming endpoints unbuffered.
mux.Handle("/", gzipStatic(newCanaryHandler(*root, *reactRoot)))
httpSrv := &http.Server{
Addr: *addr,
@@ -90,7 +97,7 @@ type spaHandler struct {
fileSrv http.Handler
}
func newSPAHandler(root string) http.Handler {
func newSPAHandler(root, indexName string) *spaHandler {
// Honor the same CSP env the prior hanzoai/static image used, so the world
// CR needs no change on cutover; WORLD_CSP is an explicit alias.
h := &spaHandler{
@@ -98,14 +105,94 @@ func newSPAHandler(root string) http.Handler {
fileSrv: http.FileServer(http.Dir(root)),
csp: envOr("HANZO_STATIC_CSP", envOr("WORLD_CSP", "")),
}
if b, err := os.ReadFile(filepath.Join(root, "index.html")); err == nil {
if b, err := os.ReadFile(filepath.Join(root, indexName)); err == nil {
h.indexHTML = b
} else {
log.Printf("world: warning: no index.html under %q: %v", root, err)
log.Printf("world: warning: no %s under %q: %v", indexName, root, err)
}
return h
}
// surfaceCookie pins a browser session to the vanilla ("") or React ("react")
// world surface; ?react / ?gui toggles it. Readable by the client (not HttpOnly)
// so the app can show which surface it is on.
const surfaceCookie = "world_surface"
// canaryHandler routes each request to the vanilla SPA (default) or the React
// rewrite (opt-in). A visitor opts in with ?react (or ?gui=react): that sets the
// sticky cookie and redirects to a clean URL, so every following request (HTML,
// content-hashed assets, client routes) is served consistently from the React
// root for that session. ?react=0 / ?gui=vanilla opts back out. With no cookie
// the default surface is served — so this is a true canary: nothing changes for
// anyone until we flip the default.
type canaryHandler struct {
vanilla *spaHandler
react *spaHandler // nil when no react root is present → always vanilla
}
func newCanaryHandler(root, reactRoot string) http.Handler {
c := &canaryHandler{vanilla: newSPAHandler(root, "index.html")}
if reactRoot != "" {
// Only enable the canary if the React build actually shipped in the image
// (its index loaded); otherwise fall through to vanilla, never 404.
if r := newSPAHandler(reactRoot, "index.react.html"); r.indexHTML != nil {
c.react = r
}
}
return c
}
func (c *canaryHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
// Honor an explicit toggle on a navigation: set/clear the sticky cookie, then
// redirect to the same path without the toggle query so the address bar and
// shared links stay clean and the reload is served from the chosen root.
if (r.Method == http.MethodGet || r.Method == http.MethodHead) && hasToggle(r.URL.Query()) {
q := r.URL.Query()
want := wantsReact(q)
ck := &http.Cookie{Name: surfaceCookie, Path: "/", SameSite: http.SameSiteLaxMode}
if want {
ck.Value, ck.MaxAge = "react", 30*24*3600
} else {
ck.Value, ck.MaxAge = "", -1
}
http.SetCookie(w, ck)
q.Del("react")
q.Del("gui")
u := *r.URL
u.RawQuery = q.Encode()
http.Redirect(w, r, u.RequestURI(), http.StatusFound)
return
}
if c.react != nil && surfaceFromCookie(r) == "react" {
c.react.ServeHTTP(w, r)
return
}
c.vanilla.ServeHTTP(w, r)
}
func hasToggle(q url.Values) bool { return q.Has("react") || q.Has("gui") }
// wantsReact reads the toggle intent: ?react (bare or =1/true/on/react) or
// ?gui=react opts in; ?react=0/false/off or ?gui=<anything-else> opts out.
func wantsReact(q url.Values) bool {
if q.Has("gui") {
return strings.EqualFold(strings.TrimSpace(q.Get("gui")), "react")
}
switch strings.ToLower(strings.TrimSpace(q.Get("react"))) {
case "", "1", "true", "on", "react":
return true
default:
return false
}
}
func surfaceFromCookie(r *http.Request) string {
if ck, err := r.Cookie(surfaceCookie); err == nil {
return ck.Value
}
return ""
}
func (h *spaHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodGet && r.Method != http.MethodHead {
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
+91 -1
View File
@@ -38,7 +38,7 @@ func writeTree(t *testing.T) string {
func TestStaticGzipAndCache(t *testing.T) {
root := writeTree(t)
srv := httptest.NewServer(gzipStatic(newSPAHandler(root)))
srv := httptest.NewServer(gzipStatic(newSPAHandler(root, "index.html")))
defer srv.Close()
get := func(path, ae string) *http.Response {
@@ -121,3 +121,93 @@ func TestStaticGzipAndCache(t *testing.T) {
}
})
}
// writeReactTree lays out a minimal dist-react (its index is index.react.html).
func writeReactTree(t *testing.T) string {
t.Helper()
root := t.TempDir()
if err := os.WriteFile(filepath.Join(root, "index.react.html"),
[]byte("<!doctype html><title>world-react</title>"), 0o644); err != nil {
t.Fatal(err)
}
return root
}
func TestCanarySurfaceSelection(t *testing.T) {
vanilla := writeTree(t)
react := writeReactTree(t)
h := newCanaryHandler(vanilla, react)
// A client that does NOT follow redirects and keeps no cookies, so each call
// is explicit about what it sends.
do := func(target string, cookie *http.Cookie) *http.Response {
req := httptest.NewRequest(http.MethodGet, target, nil)
if cookie != nil {
req.AddCookie(cookie)
}
rec := httptest.NewRecorder()
h.ServeHTTP(rec, req)
return rec.Result()
}
t.Run("default is vanilla", func(t *testing.T) {
resp := do("/", nil)
body, _ := io.ReadAll(resp.Body)
if !strings.Contains(string(body), "world</title>") || strings.Contains(string(body), "world-react") {
t.Fatalf("default surface should be vanilla, got %q", body)
}
})
t.Run("?react sets the cookie and redirects to a clean URL", func(t *testing.T) {
resp := do("/?react=1&keep=me", nil)
if resp.StatusCode != http.StatusFound {
t.Fatalf("status = %d, want 302", resp.StatusCode)
}
loc := resp.Header.Get("Location")
if strings.Contains(loc, "react") || !strings.Contains(loc, "keep=me") {
t.Fatalf("Location %q should drop the toggle and keep other query", loc)
}
var set *http.Cookie
for _, c := range resp.Cookies() {
if c.Name == surfaceCookie {
set = c
}
}
if set == nil || set.Value != "react" || set.MaxAge <= 0 {
t.Fatalf("expected a sticky %s=react cookie, got %+v", surfaceCookie, set)
}
})
t.Run("cookie=react serves the React surface", func(t *testing.T) {
resp := do("/", &http.Cookie{Name: surfaceCookie, Value: "react"})
body, _ := io.ReadAll(resp.Body)
if !strings.Contains(string(body), "world-react") {
t.Fatalf("cookie=react should serve React index, got %q", body)
}
})
t.Run("?react=0 clears the cookie", func(t *testing.T) {
resp := do("/?react=0", &http.Cookie{Name: surfaceCookie, Value: "react"})
var cleared bool
for _, c := range resp.Cookies() {
if c.Name == surfaceCookie && c.MaxAge < 0 {
cleared = true
}
}
if !cleared {
t.Fatalf("?react=0 should expire the %s cookie", surfaceCookie)
}
})
t.Run("no react root → always vanilla, even with the cookie", func(t *testing.T) {
vanillaOnly := newCanaryHandler(vanilla, "")
req := httptest.NewRequest(http.MethodGet, "/", nil)
req.AddCookie(&http.Cookie{Name: surfaceCookie, Value: "react"})
rec := httptest.NewRecorder()
vanillaOnly.ServeHTTP(rec, req)
body, _ := io.ReadAll(rec.Result().Body)
if !strings.Contains(string(body), "world</title>") || strings.Contains(string(body), "world-react") {
t.Fatalf("with no react root the cookie must be ignored, got %q", body)
}
})
}
+188
View File
@@ -0,0 +1,188 @@
import { expect, test, type Page } from '@playwright/test';
/**
* React-entry CORE cutover gate (world.hanzo.ai React + @hanzo/gui surface).
*
* These specs drive the REAL React entry (index.react.html), not a harness, and prove
* the surface is cutover-ready:
* 1. shell/auth mounts — HanzoAppHeader + variant tabs render.
* 2. globe island renders — GlobeIsland instantiates the existing MapContainer
* (a WebGL canvas inside #mapContainer).
* 3. variant filter — switching a tab swaps the visible panel set (the
* rail's per-variant filter — cloud-only vs world-only).
* 4. shared panel-order — a pre-seeded `panel-order` layout is honoured (the
* cross-surface persistence contract with the vanilla app).
* 5. drag reorder — a real pointer drag reorders the grid and writes the
* SHARED `panel-order` key.
* 6. a panel's live fetch — MarketsPanel issues its live `/v1/world/finnhub` fetch
* (mocked same-origin) and renders the returned rows.
*
* The React dev server serves the React entry at /index.react.html (/, is the vanilla
* app), so every navigation targets that path.
*/
const REACT = '/index.react.html';
// Wait for the lazy PanelRail chunk to stream in and at least one panel to mount.
async function waitForRail(page: Page): Promise<void> {
await expect(page.locator('.panels-grid [data-panel]').first()).toBeVisible({ timeout: 30000 });
}
test.describe('React entry — shell + globe', () => {
test('unified shell + variant tabs mount', async ({ page }) => {
await page.goto(REACT);
// The header view-switcher is the anchor the shell hangs off — role=tablist with
// the six canonical variant tabs (Cloud · AI · Crypto · Finance · Tech · World).
const tablist = page.getByRole('tablist', { name: 'View switcher' });
await expect(tablist).toBeVisible();
for (const label of ['Cloud', 'AI', 'Crypto', 'Finance', 'Tech', 'World']) {
await expect(tablist.getByRole('tab', { name: label, exact: true })).toBeVisible();
}
});
test('globe island renders a WebGL canvas', async ({ page }) => {
await page.goto(REACT);
// GlobeIsland mounts <div id="mapContainer"> then instantiates MapContainer, which
// creates the WebGL canvas inside it. The flagship Cloud variant OPENS on the native
// 3D globe and parks the mapbox 2D map (its canvas mounts HIDDEN behind the globe),
// so assert the canvas is ATTACHED with real render dimensions rather than visible —
// the globe engine having mounted a sized WebGL surface is the contract here.
const host = page.locator('#mapContainer');
await expect(host).toBeVisible();
const canvas = host.locator('canvas').first();
await expect(canvas).toBeAttached({ timeout: 45000 });
const size = await canvas.evaluate((c) => ({
w: (c as HTMLCanvasElement).width,
h: (c as HTMLCanvasElement).height,
}));
expect(size.w).toBeGreaterThan(0);
expect(size.h).toBeGreaterThan(0);
});
});
test.describe('React entry — variant filter', () => {
test('switching tabs swaps the visible panel set', async ({ page }) => {
// Default host variant is cloud. The live-traffic globe tile is a cloud panel and
// NOT a world panel; Country Instability (cii) is a world panel and NOT cloud.
await page.goto(REACT);
await waitForRail(page);
await expect(page.locator('[data-panel="traffic-globe"]')).toHaveCount(1);
await expect(page.locator('[data-panel="cii"]')).toHaveCount(0);
// One-switch path: click the World tab; the rail re-filters to the full set.
await page.getByRole('tab', { name: 'World', exact: true }).click();
await expect(page.locator('[data-panel="cii"]')).toHaveCount(1, { timeout: 15000 });
await expect(page.locator('[data-panel="traffic-globe"]')).toHaveCount(0);
});
});
test.describe('React entry — shared panel-order', () => {
test('a pre-seeded panel-order layout is honoured', async ({ page }) => {
// The React grid reads the SAME `panel-order` localStorage key the vanilla app
// writes. Seed a specific first panel BEFORE load, then assert the grid renders it
// first — the cross-surface persistence contract.
await page.addInitScript(() => {
// World variant so the seeded ids are all in the visible set.
window.localStorage.setItem('worldmonitor-variant', 'full');
window.localStorage.setItem(
'panel-order',
JSON.stringify(['strategic-risk', 'markets', 'cii', 'economic']),
);
});
await page.goto(REACT);
await waitForRail(page);
const firstPanel = page.locator('.panels-grid > [data-panel]').first();
await expect(firstPanel).toHaveAttribute('data-panel', 'strategic-risk', { timeout: 15000 });
});
});
test.describe('React entry — drag reorder', () => {
test('a pointer drag reorders the grid and writes the shared key', async ({ page }) => {
await page.addInitScript(() => {
window.localStorage.setItem('worldmonitor-variant', 'full');
window.localStorage.removeItem('panel-order');
});
await page.goto(REACT);
await waitForRail(page);
const grid = page.locator('.panels-grid');
const order = async (): Promise<string[]> =>
grid.evaluate((g) =>
Array.from(g.children).map((c) => (c as HTMLElement).dataset.panel ?? ''),
);
const before = await order();
expect(before.length).toBeGreaterThan(2);
// Drag panel[0] down past panel[2]'s midpoint so it commits a reorder. Drive the
// real pointer plumbing (mouse → pointer events in Chromium): press on the panel
// body, cross the 6px threshold, then step down.
const p0 = page.locator(`[data-panel="${before[0]}"]`);
const p2 = page.locator(`[data-panel="${before[2]}"]`);
const b0 = await p0.boundingBox();
const b2 = await p2.boundingBox();
expect(b0 && b2).toBeTruthy();
const startX = b0!.x + b0!.width / 2;
const startY = b0!.y + 14; // near the top (header), off any interactive control
const endY = b2!.y + b2!.height * 0.7;
await page.mouse.move(startX, startY);
await page.mouse.down();
// Cross the drag threshold, then walk down in steps so the reflow tracks the ghost.
await page.mouse.move(startX, startY + 10);
for (let y = startY + 10; y <= endY; y += 24) {
await page.mouse.move(startX, y);
await page.waitForTimeout(30);
}
await page.mouse.move(startX, endY);
await page.mouse.up();
// The engine reordered the DOM on commit and PanelGrid persisted it to the shared
// key. Assert both: the first id moved AND `panel-order` was written.
await expect
.poll(async () => (await order())[0], { timeout: 10000 })
.not.toBe(before[0]);
const saved = await page.evaluate(() =>
JSON.parse(window.localStorage.getItem('panel-order') || '[]'),
);
expect(Array.isArray(saved)).toBe(true);
expect(saved.length).toBeGreaterThan(0);
});
});
test.describe('React entry — a panel live fetch renders', () => {
test('MarketsPanel fetches /v1/world/finnhub and renders rows', async ({ page }) => {
// MarketsPanel is a World-variant panel. Mock its same-origin proxy endpoints so
// the live fetch is deterministic + offline: finnhub echoes every requested symbol
// as a priced quote; the yahoo passthroughs return empty so nothing hits the wire.
await page.route('**/v1/world/finnhub**', async (route) => {
const symbols = (new URL(route.request().url()).searchParams.get('symbols') || '')
.split(',')
.filter(Boolean);
const quotes = symbols.map((symbol, i) => ({
symbol,
price: 100 + i,
changePercent: 1.23,
}));
await route.fulfill({ json: { quotes } });
});
await page.route('**/v1/world/yahoo-batch**', (route) => route.fulfill({ json: {} }));
await page.route('**/v1/world/yahoo-finance**', (route) => route.fulfill({ json: {} }));
await page.addInitScript(() => window.localStorage.setItem('worldmonitor-variant', 'full'));
await page.goto(REACT);
await waitForRail(page);
const markets = page.locator('[data-panel="markets"]');
await expect(markets).toHaveCount(1);
// The chassis renders the title uppercased; the live fetch leaves the loading state
// and paints priced rows ($NNN.NN) from the mocked quotes.
await expect(markets.getByText(/\$\d/).first()).toBeVisible({ timeout: 20000 });
});
});
+19 -14
View File
@@ -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);
+137
View File
@@ -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);
});
});
+18 -10
View File
@@ -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` });
});
+18 -3
View File
@@ -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` });
});
+40
View File
@@ -115,6 +115,46 @@ test.describe('dashboard sync (real app)', () => {
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: {} } }));
+3 -1
View File
@@ -42,7 +42,9 @@ test.describe('Enso Live Training — real models only', () => {
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 }));
await page.goto('/');
// 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);
+43 -18
View File
@@ -1,21 +1,24 @@
import { expect, test } from '@playwright/test';
// Mounts the real EnsoTrainingPanel against a stubbed router-stats payload and
// asserts the rendered DOM: opaque "Enso arm N" labels (NEVER a vendor name),
// the cost-saved headline, the retrain gate verdict, and the honest "—" shadow
// state. Mirrors e2e/investments-panel.spec.ts (runtime-harness).
// 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 opaque arms, cost-saved headline and retrain gate — no vendor names', async ({ page }) => {
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 CANNED = {
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 },
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,
@@ -23,24 +26,41 @@ test.describe('Enso Live Training panel', () => {
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 proxy the service calls.
// Stub the same-origin proxies the panel calls, by endpoint.
const origFetch = window.fetch;
window.fetch = (async () =>
({ ok: true, status: 200, json: async () => CANNED })) as typeof 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.
// 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 armLabels = Array.from(root.querySelectorAll('.cloud-model-name')).map(
const modelNames = Array.from(root.querySelectorAll('.cloud-model-name')).map(
(n) => (n.textContent ?? '').trim(),
);
const text = root.textContent ?? '';
@@ -49,14 +69,19 @@ test.describe('Enso Live Training panel', () => {
root.remove();
window.fetch = origFetch;
return { armLabels, text };
return { modelNames, text };
});
// Three opaque arms, labeled by tier — never a vendor name.
expect(result.armLabels.length).toBe(3);
expect(result.armLabels[0]).toContain('Enso arm 1');
expect(result.armLabels[1]).toContain('Enso arm 2');
expect(result.armLabels[2]).toContain('Enso arm 3');
// 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);
}
+23 -6
View File
@@ -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;
});
});
+28 -94
View File
@@ -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);
+56
View File
@@ -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);
});
});
+92
View File
@@ -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);
});
});
+9
View File
@@ -58,6 +58,15 @@ async function appReady(page: Page): Promise<void> {
/** 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');
+120
View File
@@ -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);
});
});
+42 -24
View File
@@ -37,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);
@@ -70,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);
@@ -115,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).
@@ -134,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);
});
});
+2
View File
@@ -6,6 +6,7 @@ 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
golang.org/x/net v0.54.0
)
require (
@@ -25,4 +26,5 @@ require (
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
)
+4
View File
@@ -40,7 +40,11 @@ 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.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/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
+3
View File
@@ -19,6 +19,9 @@ images:
# 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
+1 -1
View File
@@ -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 -->
+19
View File
@@ -0,0 +1,19 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Hanzo World — React + GUI (foundation)</title>
<meta
name="description"
content="Hanzo World — React 19 + @hanzo/gui foundation preview. The shipping surface remains the vanilla app at index.html."
/>
<meta name="theme-color" content="#000000" />
<meta name="robots" content="noindex" />
<link rel="icon" type="image/svg+xml" href="/favico/hanzo-favicon.svg" />
</head>
<body>
<div id="react-root"></div>
<script type="module" src="/src/react/main.tsx"></script>
</body>
</html>
+21 -12
View File
@@ -24,20 +24,20 @@ import (
// or non-admin owner → 403. The caller's bearer is returned to forward upstream,
// where cloud independently re-verifies — defense in depth.
// adminOrgs is the set of IAM owner claims world treats as a global admin: the
// base {admin, built-in} (in sync with the cloud deploy's globalAdminOrgs) plus the
// deployment's OPERATOR org, so the seeded superuser (z@hanzo.ai, owner "hanzo")
// sees the full internal dashboard. Override per deployment with WORLD_ADMIN_ORGS
// (comma-separated); unset defaults the operator org to "hanzo". The upstream cloud
// subsystems independently re-verify the bearer, so this gate only decides which
// callers world will ATTEMPT the full/admin reads for — never the final authz.
// 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}
extra := env("WORLD_ADMIN_ORGS")
if extra == "" {
extra = "hanzo" // operator org: z@hanzo.ai is the seeded superuser
}
for _, o := range strings.Split(extra, ",") {
for _, o := range strings.Split(env("WORLD_ADMIN_ORGS"), ",") {
if o = strings.TrimSpace(o); o != "" {
m[o] = true
}
@@ -48,6 +48,15 @@ func adminOrgs() map[string]bool {
// 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
// subsystems are served from. HANZO_AI_BASE may legitimately carry a /v1 suffix
// (it is the OpenAI-compatible base); strip it so subsystem paths like
+6
View File
@@ -42,6 +42,12 @@ func asFloat(v any) float64 {
return f
case float64:
return t
case float32:
return float64(t)
case int:
return float64(t)
case int64:
return float64(t)
case string:
f, _ := strconv.ParseFloat(t, 64)
return f
+233
View File
@@ -0,0 +1,233 @@
package world
import (
"errors"
"sort"
"sync"
"time"
)
// fund_broker.go is the PAPER / LIVE boundary — the one seam the whole fund
// brain routes execution through.
//
// SAFETY INVARIANT (enforced by the type system and proven in fund_broker_test.go):
// the brain is fully autonomous for analysis and decision-making, but it can
// only ever reach a Broker whose sole real implementation is PaperBroker, which
// records simulated fills against an in-memory ledger and touches no external
// venue. LiveBroker is a compile-present stub that returns errLiveExecution on
// every call: no brokerage/exchange client, no order-router, no credential is
// wired anywhere in this package. Moving real funds requires a human to build
// and authorize a real implementation — it cannot happen by running this code.
// errLiveExecution is returned by every LiveBroker method. Live trading is a
// human-authorized action, never an autonomous one.
var errLiveExecution = errors.New("live execution requires human authorization")
// side is the direction of a paper order.
type side string
const (
buy side = "buy"
sell side = "sell"
)
// order is one instruction the engine emits to rebalance toward the book. Notional
// is in the fund's simulated base currency (USD), always positive.
type order struct {
Sleeve string `json:"sleeve"`
Side side `json:"side"`
Notional float64 `json:"notional"`
Reason string `json:"reason"`
At time.Time `json:"at"`
}
// fill is the broker's response to an order: what actually "executed". For the
// paper broker a fill mirrors the order exactly (no slippage model — this is a
// weight-tracking simulation, not an execution simulator).
type fill struct {
Order order `json:"order"`
Filled float64 `json:"filled"` // notional filled (== order.Notional for paper)
Paper bool `json:"paper"` // always true; a real fill would be false
At time.Time `json:"at"`
}
// Broker is the execution seam. The engine holds a Broker, never a concrete type,
// so the only way to make it place a real order is to give it a real
// implementation — which this package deliberately does not provide.
type Broker interface {
// Execute records (paper) or would place (live, refused) a batch of orders,
// returning the resulting fills. It must be safe for concurrent use.
Execute(orders []order) ([]fill, error)
// Live reports whether this broker moves real funds. The engine asserts it is
// false before it runs; a true here is a wiring bug that must fail loudly.
Live() bool
}
// PaperBroker is the ONLY real Broker. It "fills" every order at its full
// notional and appends nothing external — the position/PnL accounting lives in
// the ledger the engine keeps. Stateless and trivially concurrency-safe.
type PaperBroker struct{}
// NewPaperBroker returns the paper broker. This is the only Broker constructor
// the engine ever calls.
func NewPaperBroker() *PaperBroker { return &PaperBroker{} }
// Execute fills every order at full notional as a paper fill.
func (PaperBroker) Execute(orders []order) ([]fill, error) {
now := time.Now().UTC()
fills := make([]fill, 0, len(orders))
for _, o := range orders {
fills = append(fills, fill{Order: o, Filled: o.Notional, Paper: true, At: now})
}
return fills, nil
}
// Live is always false: the paper broker never moves real funds.
func (PaperBroker) Live() bool { return false }
// LiveBroker is a stub. It implements Broker so the seam is visibly complete, but
// every method refuses: there is no venue client, and there never will be one in
// this package. It exists to make the boundary explicit and to prove in tests
// that no autonomous path can execute for real.
type LiveBroker struct{}
// Execute always refuses. Real execution is a human-authorized action.
func (LiveBroker) Execute([]order) ([]fill, error) { return nil, errLiveExecution }
// Live is true: this broker WOULD move real funds — which is exactly why the
// engine refuses to run with it (fund_engine.go asserts Broker.Live() == false).
func (LiveBroker) Live() bool { return true }
// ── the paper ledger ─────────────────────────────────────────────────────────
// position is the fund's simulated holding in one sleeve: the invested cost
// basis (net notional bought minus sold) and the sleeve's current model weight.
type position struct {
Sleeve string `json:"sleeve"`
Cost float64 `json:"cost"` // net notional invested (paper), USD
Weight float64 `json:"weight"` // latest target weight from the book, [0,1]
}
// ledger is the append-only record of the paper fund: current positions, the
// full order/fill history, and the running simulated PnL. Guarded by its own
// mutex; the engine writes it, the /ledger handler reads a snapshot.
type ledger struct {
mu sync.Mutex
cash float64 // uninvested simulated capital, USD
capital float64 // starting capital, USD (for return %)
positions map[string]position // sleeve → position
history []fill // append-only fill log (most recent last)
rebalance int // number of rebalance cycles applied
lastAt time.Time // time of the last applied cycle
markValue float64 // last marked portfolio value (positions marked to weight×equity)
}
// newLedger opens a paper ledger with the given starting capital, fully in cash.
func newLedger(capital float64) *ledger {
return &ledger{
cash: capital,
capital: capital,
positions: map[string]position{},
}
}
// apply records a batch of paper fills into the ledger: a buy moves cash into a
// sleeve's cost basis, a sell moves it back. Cost basis is floored at zero (a
// sell never drives a paper position negative — the engine only sells what it
// holds). Append-only: every fill is kept in history.
func (l *ledger) apply(fills []fill) {
l.mu.Lock()
defer l.mu.Unlock()
for _, f := range fills {
p := l.positions[f.Order.Sleeve]
p.Sleeve = f.Order.Sleeve
switch f.Order.Side {
case buy:
p.Cost += f.Filled
l.cash -= f.Filled
case sell:
cut := f.Filled
if cut > p.Cost {
cut = p.Cost
}
p.Cost -= cut
l.cash += cut
}
l.positions[f.Order.Sleeve] = p
l.history = append(l.history, f)
}
l.rebalance++
l.lastAt = time.Now().UTC()
}
// setWeights records the latest target weights from the book onto positions so a
// ledger snapshot shows the model's intended allocation next to the paper cost.
func (l *ledger) setWeights(scores []sleeveScore) {
l.mu.Lock()
defer l.mu.Unlock()
for _, s := range scores {
p := l.positions[s.key]
p.Sleeve = s.key
p.Weight = s.weight
l.positions[s.key] = p
}
}
// investedCost is the total paper capital currently allocated across sleeves.
func (l *ledger) investedCost() float64 {
var sum float64
for _, p := range l.positions {
sum += p.Cost
}
return sum
}
// snapshotPositions returns a copy of the current positions map so the pure
// diffOrders can read a consistent view without holding the ledger lock.
func (l *ledger) snapshotPositions() map[string]position {
l.mu.Lock()
defer l.mu.Unlock()
out := make(map[string]position, len(l.positions))
for k, v := range l.positions {
out[k] = v
}
return out
}
// mark values the paper book: each held sleeve is marked by its latest model
// weight against the current equity, and the simulated PnL is the marked value
// plus cash minus starting capital. This is a weight-tracking mark — the sim
// measures whether the model's ALLOCATION tracks the sleeve reads, not tick PnL.
func (l *ledger) mark(scores []sleeveScore) {
weight := make(map[string]float64, len(scores))
for _, s := range scores {
weight[s.key] = s.weight
}
l.mu.Lock()
defer l.mu.Unlock()
equity := l.capital
var marked float64
for k, p := range l.positions {
// A sleeve still in the book marks at target×equity; one that dropped out
// marks at its remaining cost basis (it is being wound down).
if w, ok := weight[k]; ok {
marked += w * equity
} else {
marked += p.Cost
}
}
l.markValue = marked
}
// pnl is the simulated profit/loss: marked positions + uninvested cash start.
func (l *ledger) pnl() float64 {
l.mu.Lock()
defer l.mu.Unlock()
return l.markValue + l.cash - l.capital
}
// sortOrders orders a batch deterministically by sleeve key so identical inputs
// yield identical ledger history.
func sortOrders(os []order) {
sort.SliceStable(os, func(i, j int) bool { return os[i].Sleeve < os[j].Sleeve })
}
+173
View File
@@ -0,0 +1,173 @@
package world
import (
"context"
"fmt"
"math"
"sync"
"time"
)
// fund_engine.go is the autonomous brain. On an interval it recomputes the book
// (scoreSleeves), diffs the preferred portfolio against the current paper
// positions, and routes rebalance orders through the Broker seam into the
// append-only paper ledger, tracking simulated PnL. It is analysis + decision
// only: the Broker it holds is a PaperBroker, asserted non-live before every run.
const (
// fundCapital is the paper fund's starting simulated capital, USD.
fundCapital = 1_000_000.0
// fundInterval is the autonomous rebalance cadence.
fundInterval = 6 * time.Hour
// rebalanceBand is the minimum absolute weight drift (target held) that
// justifies an order, so tiny model wiggles don't churn the paper book.
rebalanceBand = 0.02
// fundRange is the price history window the book is scored over.
fundRange = "range=6mo&interval=1d"
// fundFetchTO bounds each per-symbol Yahoo fetch.
fundFetchTO = 8 * time.Second
)
// fundEngine owns the paper fund: the execution seam (broker), the ledger, and
// the latest computed book. Safe for concurrent use — the ledger guards its own
// state and book is swapped under bookMu.
type fundEngine struct {
broker Broker
led *ledger
bookMu sync.Mutex
lastBook *fundBook
}
// fundBook is the computed preferred portfolio at a point in time.
type fundBook struct {
asOf time.Time
scores []sleeveScore
stance string
riskFr float64
}
// newFundEngine builds the paper fund. It refuses any live broker: the fund is
// paper-only by construction, and a live broker here is a wiring bug.
func newFundEngine(b Broker) *fundEngine {
if b == nil || b.Live() {
// Fail closed to the paper broker rather than ever run live autonomously.
b = NewPaperBroker()
}
return &fundEngine{broker: b, led: newLedger(fundCapital)}
}
// diffOrders is the pure rebalancer: given the target weights (the book) and the
// current paper positions, emit the buy/sell orders that move each sleeve's held
// fraction toward its target, in notional against `equity`. A sleeve only trades
// when it drifts past rebalanceBand. Sells come before buys so freed cash funds
// the buys within one cycle. Pure and fully unit-tested.
func diffOrders(scores []sleeveScore, positions map[string]position, equity float64, at time.Time) []order {
if equity <= 0 {
return nil
}
target := make(map[string]float64, len(scores))
for _, s := range scores {
target[s.key] = s.weight
}
// Every sleeve that is either targeted now or held from before must be
// considered, so a sleeve that dropped out of the book is sold down.
keys := make(map[string]bool, len(scores)+len(positions))
for k := range target {
keys[k] = true
}
for k := range positions {
keys[k] = true
}
var sells, buys []order
for k := range keys {
held := positions[k].Cost / equity
want := target[k]
drift := want - held
if math.Abs(drift) < rebalanceBand {
continue
}
notional := math.Abs(drift) * equity
if drift < 0 {
sells = append(sells, order{
Sleeve: k, Side: sell, Notional: notional, At: at,
Reason: fmt.Sprintf("trim to %.0f%% (held %.0f%%)", want*100, held*100),
})
} else {
buys = append(buys, order{
Sleeve: k, Side: buy, Notional: notional, At: at,
Reason: fmt.Sprintf("add to %.0f%% (held %.0f%%)", want*100, held*100),
})
}
}
// Deterministic order (sleeve key) so identical inputs give identical output —
// tests and the ledger history stay stable.
sortOrders(sells)
sortOrders(buys)
return append(sells, buys...)
}
// rebalance applies one autonomous cycle: score the fresh closes into a book,
// record the target weights, diff against the paper positions, execute through
// the broker, and fold the fills into the ledger. Returns the book it acted on.
func (e *fundEngine) rebalance(closes map[string][]float64) (*fundBook, error) {
scores := scoreSleeves(closes)
if len(scores) == 0 {
return nil, errUnavailable
}
stance, frac := overallStance(scores)
book := &fundBook{asOf: time.Now().UTC(), scores: scores, stance: stance, riskFr: frac}
e.led.setWeights(scores)
equity := e.led.capital // rebalance against constant starting equity (paper, fully-invested target)
orders := diffOrders(scores, e.led.snapshotPositions(), equity, book.asOf)
if len(orders) > 0 {
fills, err := e.broker.Execute(orders)
if err != nil {
return nil, err
}
e.led.apply(fills)
}
e.led.mark(scores)
e.bookMu.Lock()
e.lastBook = book
e.bookMu.Unlock()
return book, nil
}
// book returns the last computed book (nil until the first cycle).
func (e *fundEngine) book() *fundBook {
e.bookMu.Lock()
defer e.bookMu.Unlock()
return e.lastBook
}
// start runs the autonomous loop: rebalance once shortly after boot, then every
// fundInterval, until ctx is cancelled. The fetch is bounded and single-sourced
// through the server's shared Yahoo client. Never live: broker is paper.
func (e *fundEngine) start(ctx context.Context, s *Server) {
go func() {
e.runOnce(ctx, s)
for {
select {
case <-ctx.Done():
return
case <-time.After(jitter(fundInterval)):
e.runOnce(ctx, s)
}
}
}()
}
// runOnce fetches the universe and applies one rebalance cycle, logging (never
// crashing) on failure so a thin-data window just skips.
func (e *fundEngine) runOnce(ctx context.Context, s *Server) {
fctx, cancel := context.WithTimeout(ctx, 60*time.Second)
defer cancel()
closes := s.fetchCloses(fctx, fundUniverse())
if _, err := e.rebalance(closes); err != nil {
logf("fund-brain: rebalance skipped: %v", err)
}
}
+180
View File
@@ -0,0 +1,180 @@
package world
import (
"math"
"sort"
)
// The fund model. A multi-asset-class conviction book layered on the same RRG
// kernel the rotation scanner computes (handlers_rotation.go): each asset-class
// SLEEVE is an equal-weight synthetic scored on the RS-Ratio / RS-Momentum plane
// against the benchmark (SPY), and conviction is the quadrant base plus a
// momentum tilt plus an oversold bonus — the faithful Go port of the reference
// model in research/rotation_model.py §5 (the "Book").
//
// The book is a PREFERRED PORTFOLIO, not an instruction to trade: model output,
// not investment advice. The autonomous engine (fund_engine.go) consumes it to
// drive a PAPER ledger only — see the Broker seam in fund_broker.go.
// A sleeve is a named asset-class basket priced as one equal-weight synthetic.
// Members index to 100 at the aligned window start so a $2 fund and a $900 coin
// contribute equally (basketSynthetic, shared with the rotation scanner).
type sleeve struct {
key, label, class string
members []string
}
// fundSleeves is the multi-asset universe: the AI buildout and its power bill
// (energy complex), the monetary/hard-asset hedges (Bitcoin, gold, silver), the
// on-chain risk sleeve (DeFi), and real assets (real estate). SPY is the
// benchmark (rotationBenchmark) and is priced separately.
var fundSleeves = []sleeve{
{"ai", "AI", "Equities · AI buildout", []string{"SMH", "SOXX", "NVDA", "AMD", "AVGO", "MSFT", "GOOGL", "META"}},
{"bitcoin", "Bitcoin", "Digital assets", []string{"IBIT", "BTC-USD", "COIN", "MSTR"}},
{"defi", "DeFi", "Digital assets", []string{"ETH-USD", "SOL-USD", "UNI-USD", "AAVE-USD"}},
{"gold", "Gold", "Precious metals", []string{"GLD", "IAU", "GDX"}},
{"silver", "Silver", "Precious metals", []string{"SLV", "SIL", "PSLV"}},
{"uranium", "Uranium", "Energy complex", []string{"URA", "URNM", "CCJ"}},
{"realestate", "Real estate", "Real assets", []string{"XLRE", "VNQ", "IYR"}},
{"energy", "Energy", "Energy complex", []string{"XLE", "XOP", "CVX", "XOM"}},
{"natgas", "Natural gas", "Energy complex", []string{"UNG", "FCG", "NG=F"}},
{"nuclear", "Nuclear power", "Energy complex", []string{"VST", "CEG", "NRG", "XLU"}},
}
// fundUniverse is every distinct symbol to fetch (sleeves benchmark), deduped.
func fundUniverse() []string {
seen := map[string]bool{rotationBenchmark: true}
out := []string{rotationBenchmark}
for _, sl := range fundSleeves {
for _, sym := range sl.members {
if !seen[sym] {
seen[sym] = true
out = append(out, sym)
}
}
}
return out
}
// Conviction weighting — the faithful port of research/rotation_model.py §5.
//
// QUAD_BASE = {improving 1.0, leading 0.72, weakening 0.22, lagging 0.08}
// conviction = QUAD_BASE + clip((mom-100)*0.16, -1.2, 1.2)
// + (improving & ret63<0 ? min(0.3, -ret63/100) : 0)
//
// Accumulate the turn-up (Improving), hold the leader (Leading), trim the topping
// leader (Weakening), avoid the falling laggard (Lagging); tilt by momentum; and
// pay a small bonus to a deeply-oversold theme that is turning up.
var quadBase = map[string]float64{
"improving": 1.0, "leading": 0.72, "weakening": 0.22, "lagging": 0.08,
}
var quadStance = map[string]string{
"improving": "Accumulate", "leading": "Core", "weakening": "Trim", "lagging": "Avoid",
}
// conviction scores one sleeve from its latest RRG point and 3-month return.
func conviction(p rrgPoint, ret63 float64) float64 {
c := quadBase[p.quadrant()] + clampf((p.mom-100)*0.16, -1.2, 1.2)
if p.quadrant() == "improving" && ret63 < 0 {
c += math.Min(0.3, -ret63/100)
}
if c < 0 {
return 0
}
return c
}
// clampf clamps v to [lo, hi]. (clamp01 is the [0,1] special case in
// handlers_cloud_router_pref.go; this is the general form the tilt needs.)
func clampf(v, lo, hi float64) float64 {
if v < lo {
return lo
}
if v > hi {
return hi
}
return v
}
// sleeveScore is one sleeve's full read: its RRG point, returns, and conviction.
type sleeveScore struct {
key, label, class, stance string
ratio, mom float64
quadrant string
ret21, ret63 float64
conviction float64
weight float64 // normalized preferred-portfolio weight, [0,1]
}
// scoreSleeves builds every sleeve's synthetic, scores it on the RRG plane, and
// normalizes conviction across the sleeves that have enough data into a preferred
// portfolio (weights sum to 1). A sleeve whose members are all too thin is
// dropped. Pure — the network lives in the caller (fetchCloses). This is the
// engine of both /v1/world/fund and the autonomous rebalancer.
func scoreSleeves(closes map[string][]float64) []sleeveScore {
bench := closes[rotationBenchmark]
if len(bench) < rrgLevelWindow+rrgMomLookback+1 {
return nil
}
scores := make([]sleeveScore, 0, len(fundSleeves))
var total float64
for _, sl := range fundSleeves {
members := make([][]float64, 0, len(sl.members))
for _, sym := range sl.members {
series := closes[sym]
if len(series) < rrgLevelWindow+rrgMomLookback+1 {
continue
}
members = append(members, series)
}
if len(members) == 0 {
continue
}
synth := basketSynthetic(members)
p, ok := rrgLatest(relSeries(synth, bench))
if !ok {
continue
}
ret63 := pctReturn(synth, 63)
c := conviction(p, ret63)
total += c
scores = append(scores, sleeveScore{
key: sl.key, label: sl.label, class: sl.class, stance: quadStance[p.quadrant()],
ratio: p.ratio, mom: p.mom, quadrant: p.quadrant(),
ret21: pctReturn(synth, 21), ret63: ret63, conviction: c,
})
}
if total > 0 {
for i := range scores {
scores[i].weight = scores[i].conviction / total
}
}
// Preferred portfolio order: highest conviction first.
sort.SliceStable(scores, func(i, j int) bool { return scores[i].conviction > scores[j].conviction })
return scores
}
// overallStance summarizes the book in one word from the aggregate posture:
// how much conviction sits in the accumulate/core sleeves vs trim/avoid.
func overallStance(scores []sleeveScore) (string, float64) {
var risk, total float64
for _, s := range scores {
total += s.weight
if s.quadrant == "improving" || s.quadrant == "leading" {
risk += s.weight
}
}
if total == 0 {
return "neutral", 0
}
frac := risk / total
switch {
case frac >= 0.66:
return "risk-on", frac
case frac >= 0.40:
return "balanced", frac
default:
return "risk-off", frac
}
}
+1
View File
@@ -177,6 +177,7 @@ func TestAIPulseAdminPoll(t *testing.T) {
t.Setenv("HANZO_CLOUD_PULSE_TOKEN", "") // no service token — the admin's bearer drives it
t.Setenv("HANZO_API_BASE", up.URL)
t.Setenv("HANZO_IAM_ISSUER", iam.URL)
t.Setenv("WORLD_ADMIN_ORGS", "hanzo") // operator org resolves via deploy env, not code
s := NewServer()
mux := http.NewServeMux()
+489
View File
@@ -0,0 +1,489 @@
package world
import (
"bytes"
"context"
"encoding/json"
"fmt"
"net/http"
"regexp"
"strconv"
"strings"
"time"
)
// Alt-asset feeds behind the finance-terminal "Art & Collectibles — Auctions"
// and "Luxury Real Estate" panels (src/components/finance/AltFeedPanel.ts).
// Neither headline source is usable server-side, so each panel is served by a
// reachable, public, respectfully-scraped equivalent — fetched once an hour and
// served from cache to every caller. Both degrade to an honest empty
// {items:[]} (the SPA then renders "live feed pending") and NEVER fabricate a
// row.
//
// /v1/world/auctions → Christie's public results (realized SALE
// totals). Sotheby's — the requested headline — gates realized prices behind
// a login (its /data/api/asset.saleresults.json returns 401 "Not signed in"),
// so the honest public major house is Christie's, exactly the fallback the
// panel names ("Sotheby's / major-house results").
//
// /v1/world/luxury-realestate → LuxuryEstate.com listings. JamesEdition — the
// requested headline — sits behind a Cloudflare JS challenge that blocks
// datacenter egress (the pod runs in DOKS), so it cannot be fetched
// server-side; LuxuryEstate is a reachable, robots-permitted real source of
// the same thing (luxury listings with a price, place, type, image, link).
//
// The SPA reads exactly {items: AltFeedItem[]} where AltFeedItem is
// {title, subtitle?, price?, href?, imageUrl?, meta?}. Extra top-level fields
// (source, asOf, count, pending) are ignored by the panel and kept for
// humans/debugging + honest attribution.
const (
christiesResultsURL = "https://www.christies.com/en/results"
luxuryEstateURL = "https://www.luxuryestate.com/united-states?currency=USD"
auctionsCacheKey = "auctions:v1"
luxuryRealtyCacheKey = "luxury-realestate:v1"
altAssetsTTL = time.Hour // fresh window: at most one scrape/source/hour
altAssetsStale = 24 * time.Hour // serve last-good for a day through any source outage
altAssetsRefresh = time.Hour // background warmer cadence
altFeedMaxItems = 12 // the panel itself renders at most 12
altFeedCacheControl = "public, max-age=3600, s-maxage=3600, stale-while-revalidate=86400"
)
// altFeedItem mirrors the SPA's AltFeedItem exactly (see AltFeedPanel.ts). JSON
// tags match field-for-field; omitempty keeps optional fields absent, not empty.
type altFeedItem struct {
Title string `json:"title"`
Subtitle string `json:"subtitle,omitempty"`
Price string `json:"price,omitempty"`
Href string `json:"href,omitempty"`
ImageURL string `json:"imageUrl,omitempty"`
Meta string `json:"meta,omitempty"`
}
// handleAuctions serves /v1/world/auctions (Christie's realized sale totals).
func (s *Server) handleAuctions(w http.ResponseWriter, r *http.Request) {
if preflight(w, r, "GET, OPTIONS") || methodNotGet(w, r) {
return
}
s.cachedJSON(w, auctionsCacheKey, altFeedCacheControl, altAssetsTTL, altAssetsStale,
func(ctx context.Context) (any, error) { return s.computeAuctions(ctx) },
func(w http.ResponseWriter, _ error) { writeAltFeedPending(w, "Christie's") })
}
// handleLuxuryRealestate serves /v1/world/luxury-realestate (LuxuryEstate listings).
func (s *Server) handleLuxuryRealestate(w http.ResponseWriter, r *http.Request) {
if preflight(w, r, "GET, OPTIONS") || methodNotGet(w, r) {
return
}
s.cachedJSON(w, luxuryRealtyCacheKey, altFeedCacheControl, altAssetsTTL, altAssetsStale,
func(ctx context.Context) (any, error) { return s.computeLuxuryRealestate(ctx) },
func(w http.ResponseWriter, _ error) { writeAltFeedPending(w, "LuxuryEstate") })
}
// writeAltFeedPending is the honest cold-miss fallback (no cache yet AND the
// source is unreachable): an empty item list with a pending flag. The SPA
// renders "Live feed pending" for an empty list. Never a fabricated row, never a
// 5xx — so the routes smoke sweep and the panel both stay green.
func writeAltFeedPending(w http.ResponseWriter, source string) {
writeJSON(w, http.StatusOK, "", map[string]any{
"asOf": nowISO(), "source": source, "pending": true, "count": 0,
"items": []altFeedItem{},
})
}
// altFeedPayload is the shaped, cacheable success value. produce returns an error
// (not this) when it parses zero rows, so an empty list is never cached — the
// caller then serves the last-good copy or the pending fallback.
func altFeedPayload(source string, items []altFeedItem) map[string]any {
if len(items) > altFeedMaxItems {
items = items[:altFeedMaxItems]
}
return map[string]any{
"asOf": nowISO(), "source": source, "count": len(items), "items": items,
}
}
// ── auctions: Christie's ──────────────────────────────────────────────────────
func (s *Server) computeAuctions(ctx context.Context) (any, error) {
body, status, err := s.get(ctx, christiesResultsURL, map[string]string{
"Accept": "text/html,application/xhtml+xml",
"Accept-Language": "en-US,en;q=0.9",
"User-Agent": browserUA,
})
if err != nil {
return nil, err
}
if status < 200 || status >= 300 {
return nil, fmt.Errorf("christies status %d", status)
}
items := parseChristiesAuctions(body)
if len(items) == 0 {
return nil, errUnavailable
}
return altFeedPayload("Christie's", items), nil
}
// christieSale is the subset of Christie's embedded results JSON we render. Only
// scalars are unmarshalled; the image URL is pulled from the raw object bytes
// (its nested srcset schema is picked over by regex, so a schema tweak can't
// break the whole parse).
type christieSale struct {
TitleTxt string `json:"title_txt"`
SubtitleTxt string `json:"subtitle_txt"`
DateDisplayTxt string `json:"date_display_txt"`
LocationTxt string `json:"location_txt"`
SaleTotalValueTxt string `json:"sale_total_value_txt"`
LandingURL string `json:"landing_url"`
}
// realizedTotalRe matches a realized total like "USD 1,135,380" / "HKD 10,319,750"
// — a 23 letter ISO currency code, a space, then a grouped amount. It filters
// out upcoming/estimate-only cards that carry no realized figure.
var realizedTotalRe = regexp.MustCompile(`^[A-Z]{2,3}\s[\d,]+$`)
// christiesImgRe matches a Christie's sale image URL and captures its width, so
// pickChristiesImage can choose a mid-size variant.
var christiesImgRe = regexp.MustCompile(`https://www\.christies\.com/img/SaleImages/[^"\\ ]+?\.jpg\?w=(\d+)`)
// parseChristiesAuctions extracts the embedded "events":[…] results array from
// the SSR HTML and maps each realized sale to an altFeedItem. Malformed input
// yields an empty slice, never a panic.
func parseChristiesAuctions(body []byte) []altFeedItem {
arr := extractBracketed(body, `"events":`, '[', ']')
if arr == nil {
return nil
}
var sales []json.RawMessage
if err := json.Unmarshal(arr, &sales); err != nil {
return nil
}
out := make([]altFeedItem, 0, len(sales))
for _, raw := range sales {
var sale christieSale
if err := json.Unmarshal(raw, &sale); err != nil {
continue
}
price := strings.TrimSpace(sale.SaleTotalValueTxt)
title := strings.TrimSpace(sale.TitleTxt)
if title == "" || !realizedTotalRe.MatchString(price) {
continue // upcoming / estimate-only / malformed — not a realized result
}
cat := classifyAuction(title + " " + sale.SubtitleTxt)
item := altFeedItem{
Title: title,
Price: price,
Href: absURL(sale.LandingURL, "https://www.christies.com"),
ImageURL: pickChristiesImage(raw),
Subtitle: "Christie's",
}
if loc := strings.TrimSpace(sale.LocationTxt); loc != "" {
item.Subtitle = "Christie's · " + loc
}
if d := strings.TrimSpace(sale.DateDisplayTxt); d != "" {
item.Meta = cat + " · " + d
} else {
item.Meta = cat
}
out = append(out, item)
}
return out
}
// pickChristiesImage returns the sale image whose width is closest to 640px (a
// crisp thumbnail without pulling the largest hero), scanning the raw object.
func pickChristiesImage(raw []byte) string {
best := ""
bestDiff := 1 << 30
for _, m := range christiesImgRe.FindAllSubmatch(raw, -1) {
w, err := strconv.Atoi(string(m[1]))
if err != nil || w <= 0 {
continue
}
d := w - 640
if d < 0 {
d = -d
}
if d < bestDiff {
bestDiff, best = d, string(m[0])
}
}
return best
}
// auctionCategory pairs a substring probe (lowercased title+subtitle) with the
// category label shown to the user. Ordered: first match wins.
var auctionCategories = []struct{ probe, label string }{
{"cellar", "Wine & Spirits"}, {"wine", "Wine & Spirits"}, {"whisky", "Wine & Spirits"},
{"whiskey", "Wine & Spirits"}, {"spirit", "Wine & Spirits"}, {"vintage", "Wine & Spirits"},
{"watch", "Watches"}, {"rolex", "Watches"}, {"patek", "Watches"}, {"horolog", "Watches"},
{"jewel", "Jewellery"}, {"diamond", "Jewellery"}, {"gemstone", "Jewellery"},
{"handbag", "Handbags & Accessories"}, {"hermès", "Handbags & Accessories"},
{"hermes", "Handbags & Accessories"}, {"birkin", "Handbags & Accessories"},
{"coin", "Gold & Coins"}, {"numismat", "Gold & Coins"}, {"bullion", "Gold & Coins"},
{"gold", "Gold & Coins"},
}
// classifyAuction infers a human category from the sale name (the source has no
// stable category label). It is a best-effort hint, never a fabricated fact;
// anything unmatched is the honest generic "Art & Collectibles".
func classifyAuction(text string) string {
t := strings.ToLower(text)
for _, c := range auctionCategories {
if strings.Contains(t, c.probe) {
return c.label
}
}
return "Art & Collectibles"
}
// ── luxury real estate: LuxuryEstate ──────────────────────────────────────────
func (s *Server) computeLuxuryRealestate(ctx context.Context) (any, error) {
body, status, err := s.get(ctx, luxuryEstateURL, map[string]string{
"Accept": "text/html,application/xhtml+xml",
"Accept-Language": "en-US,en;q=0.9",
"User-Agent": browserUA,
})
if err != nil {
return nil, err
}
if status < 200 || status >= 300 {
return nil, fmt.Errorf("luxuryestate status %d", status)
}
items := parseLuxuryEstateListings(body)
if len(items) == 0 {
return nil, errUnavailable
}
return altFeedPayload("LuxuryEstate", items), nil
}
// luxuryListing is the subset of a LuxuryEstate propertiesList entry we render.
type luxuryListing struct {
Title string `json:"title"`
Label string `json:"label"`
Type string `json:"type"`
URL string `json:"url"`
Surface string `json:"surface"`
Bedrooms string `json:"bedrooms"`
PictureThumb string `json:"pictureThumb"`
Picture string `json:"picture"`
Price struct {
Amount string `json:"amount"`
Currency string `json:"currency"`
CurrencySymbol string `json:"currencySymbol"`
} `json:"price"`
GeoInfo struct {
Country string `json:"country"`
Region string `json:"region"`
City string `json:"city"`
} `json:"geoInfo"`
}
// parseLuxuryEstateListings extracts the embedded "propertiesList":[…] array and
// maps each listing to an altFeedItem. Malformed input yields an empty slice.
func parseLuxuryEstateListings(body []byte) []altFeedItem {
arr := extractBracketed(body, `"propertiesList":`, '[', ']')
if arr == nil {
return nil
}
var listings []luxuryListing
if err := json.Unmarshal(arr, &listings); err != nil {
return nil
}
out := make([]altFeedItem, 0, len(listings))
for _, l := range listings {
title := strings.TrimSpace(l.Title)
if title == "" {
title = strings.TrimSpace(l.Label)
}
if title == "" {
continue
}
img := strings.TrimSpace(l.PictureThumb)
if img == "" {
img = strings.TrimSpace(l.Picture)
}
out = append(out, altFeedItem{
Title: title,
Price: luxuryPrice(l),
Subtitle: luxuryLocation(l),
Href: absURL(l.URL, "https://www.luxuryestate.com"),
ImageURL: absScheme(img),
Meta: luxuryMeta(l),
})
}
return out
}
// luxuryPrice renders the listing's real price + currency, preferring the
// currency symbol ("US$5,950,000"), falling back to the currency code.
func luxuryPrice(l luxuryListing) string {
amt := strings.TrimSpace(l.Price.Amount)
if amt == "" {
return ""
}
if sym := strings.TrimSpace(l.Price.CurrencySymbol); sym != "" {
return sym + amt
}
if c := strings.TrimSpace(l.Price.Currency); c != "" {
return amt + " " + c
}
return amt
}
// luxuryLocation joins city with region (else country): "Banner Elk, North Carolina".
func luxuryLocation(l luxuryListing) string {
parts := make([]string, 0, 2)
if c := strings.TrimSpace(l.GeoInfo.City); c != "" {
parts = append(parts, c)
}
if r := strings.TrimSpace(l.GeoInfo.Region); r != "" {
parts = append(parts, r)
} else if c := strings.TrimSpace(l.GeoInfo.Country); c != "" {
parts = append(parts, c)
}
return strings.Join(parts, ", ")
}
// luxuryMeta renders "House · 5 bd · 655 m²" from whatever fields are present.
func luxuryMeta(l luxuryListing) string {
parts := make([]string, 0, 3)
if t := strings.TrimSpace(l.Type); t != "" {
parts = append(parts, t)
}
if b := strings.TrimSpace(l.Bedrooms); b != "" && b != "0" {
parts = append(parts, b+" bd")
}
if s := strings.TrimSpace(l.Surface); s != "" {
parts = append(parts, s)
}
return strings.Join(parts, " · ")
}
// ── shared parsing helpers ────────────────────────────────────────────────────
// extractBracketed returns the substring from the first `open` bracket that
// follows key through its matching `close` bracket, honoring JSON string escapes
// so brackets inside string values don't unbalance the scan. It is a targeted
// extractor for one embedded array/object inside a larger HTML/JS document — so
// the whole page never has to be parsed. Only whitespace or a colon may sit
// between key and the bracket. Returns nil when absent or unbalanced.
func extractBracketed(body []byte, key string, open, close byte) []byte {
i := bytes.Index(body, []byte(key))
if i < 0 {
return nil
}
j := i + len(key)
for j < len(body) && body[j] != open {
switch body[j] {
case ' ', '\t', '\n', '\r', ':':
j++
default:
return nil
}
}
if j >= len(body) {
return nil
}
depth, inStr, esc := 0, false, false
for k := j; k < len(body); k++ {
c := body[k]
switch {
case esc:
esc = false
case c == '\\':
esc = true
case c == '"':
inStr = !inStr
case inStr:
// inside a string: ignore brackets
case c == open:
depth++
case c == close:
depth--
if depth == 0 {
return body[j : k+1]
}
}
}
return nil
}
// absURL resolves a possibly-relative URL against base: "//host/…" → https://host/…,
// "/path" → base+/path, anything absolute is returned unchanged.
func absURL(u, base string) string {
u = strings.TrimSpace(u)
switch {
case u == "":
return ""
case strings.HasPrefix(u, "//"):
return "https:" + u
case strings.HasPrefix(u, "/"):
return base + u
default:
return u
}
}
// absScheme prepends https: to a scheme-relative ("//host/…") URL; leaves an
// already-absolute URL untouched.
func absScheme(u string) string {
u = strings.TrimSpace(u)
if strings.HasPrefix(u, "//") {
return "https:" + u
}
return u
}
// ── background warmer ─────────────────────────────────────────────────────────
// StartAltAssets warms the two alt-asset feeds on boot and refreshes them every
// hour until ctx is cancelled, so the panels serve a fresh cached hit instead of
// blocking on a cold scrape, and stay fresh even with sporadic traffic. These
// sources change at most daily; hourly is respectful — one request per source
// per hour — and the SWR cache keeps serving the last-good copy between
// refreshes and through any source outage. Warmer and handler share the same
// cache key + produce path + TTLs (the constants above), so they can never
// drift. Call once from main after the server is built.
func (s *Server) StartAltAssets(ctx context.Context) {
go func() {
s.warmAltAssets(ctx)
t := time.NewTicker(altAssetsRefresh)
defer t.Stop()
for {
select {
case <-ctx.Done():
return
case <-t.C:
s.warmAltAssets(ctx)
}
}
}()
}
// warmAltAssets (re)produces both feeds and stores each under the exact key its
// handler reads. A failure is logged and skipped; the next cycle retries and the
// last-good value keeps serving in the meantime.
func (s *Server) warmAltAssets(ctx context.Context) {
specs := []struct {
key string
produce func(context.Context) (any, error)
}{
{auctionsCacheKey, s.computeAuctions},
{luxuryRealtyCacheKey, s.computeLuxuryRealestate},
}
for _, spec := range specs {
if ctx.Err() != nil {
return
}
wctx, cancel := context.WithTimeout(ctx, 24*time.Second)
v, err := spec.produce(wctx)
cancel()
if err != nil {
logf("world-altassets: %s warm failed: %v", spec.key, err)
continue
}
s.cache.Set(spec.key, v, altAssetsTTL, altAssetsStale)
}
}
+166
View File
@@ -0,0 +1,166 @@
package world
import (
"encoding/json"
"net/http/httptest"
"os"
"strings"
"testing"
)
// The parsers are tested against REAL (trimmed) source captures in testdata, so
// a schema change at the source is caught here rather than in production. No
// network — deterministic. Live fetching is exercised by the routes smoke sweep
// (routes_test.go).
func TestParseChristiesAuctions(t *testing.T) {
body, err := os.ReadFile("testdata/christies_results.html")
if err != nil {
t.Fatal(err)
}
items := parseChristiesAuctions(body)
if len(items) != 2 {
t.Fatalf("want 2 items, got %d: %+v", len(items), items)
}
got := items[0]
want := altFeedItem{
Title: "The Cellar of an Obsessive Collector Part III: Online",
Subtitle: "Christie's · Hong Kong",
Price: "HKD 10,319,750",
Href: "https://onlineonly.christies.com/sso?SaleID=31386&SaleNumber=24946",
ImageURL: "https://www.christies.com/img/SaleImages/DEMO-1.jpg?w=600", // closest to 640
Meta: "Wine & Spirits · 9 21 July", // "Cellar" → wine
}
if got != want {
t.Errorf("item0 mismatch:\n got %+v\nwant %+v", got, want)
}
// A non-keyword sale falls back to the honest generic category.
if !strings.HasPrefix(items[1].Meta, "Art & Collectibles") {
t.Errorf("item1 category: want 'Art & Collectibles …', got %q", items[1].Meta)
}
if items[1].Price != "USD 1,135,380" {
t.Errorf("item1 price: got %q", items[1].Price)
}
}
func TestParseLuxuryEstateListings(t *testing.T) {
body, err := os.ReadFile("testdata/luxuryestate_results.html")
if err != nil {
t.Fatal(err)
}
items := parseLuxuryEstateListings(body)
if len(items) != 2 {
t.Fatalf("want 2 items, got %d: %+v", len(items), items)
}
got := items[0]
want := altFeedItem{
Title: "Luxury home in Banner Elk, Avery County",
Subtitle: "Banner Elk, North Carolina",
Price: "US$5,950,000",
Href: "https://www.luxuryestate.com/p131998951-luxury-home-for-sale-banner-elk",
ImageURL: "https://pic.le-cdn.com/thumbs/520x390/04/1/properties/Property-e7240000000007de000169121ee3-131998951.jpg",
Meta: "House · 5 bd · 655 m²",
}
if got != want {
t.Errorf("item0 mismatch:\n got %+v\nwant %+v", got, want)
}
if items[1].Price != "US$2,995,000" || items[1].Meta != "House · 4 bd · 403 m²" {
t.Errorf("item1: got price=%q meta=%q", items[1].Price, items[1].Meta)
}
}
func TestClassifyAuction(t *testing.T) {
cases := map[string]string{
"The Cellar of an Obsessive Collector": "Wine & Spirits",
"Fine and Rare Wines": "Wine & Spirits",
"Important Watches": "Watches",
"Magnificent Jewels and Diamonds": "Jewellery",
"Handbags & Accessories: Online": "Handbags & Accessories",
"Gold Coins of the Ancient World": "Gold & Coins",
"Tom Wesselmann: American Beauty": "Art & Collectibles", // no keyword → generic
}
for in, want := range cases {
if got := classifyAuction(in); got != want {
t.Errorf("classifyAuction(%q) = %q, want %q", in, got, want)
}
}
}
func TestExtractBracketed(t *testing.T) {
cases := []struct {
name, body, key string
open, close byte
want string
}{
{"array", `x "events": [1,2,3] y`, `"events":`, '[', ']', `[1,2,3]`},
{"nested-obj", `p "a":{"b":{"c":1}} q`, `"a":`, '{', '}', `{"b":{"c":1}}`},
{"bracket-in-string", `"k": ["a]b", "c"]`, `"k":`, '[', ']', `["a]b", "c"]`},
{"escaped-quote-in-string", `"k": ["a\"]b"]`, `"k":`, '[', ']', `["a\"]b"]`},
{"missing-key", `"other": [1]`, `"k":`, '[', ']', ""},
{"unbalanced", `"k": [1,2`, `"k":`, '[', ']', ""},
}
for _, c := range cases {
t.Run(c.name, func(t *testing.T) {
got := string(extractBracketed([]byte(c.body), c.key, c.open, c.close))
if got != c.want {
t.Errorf("extractBracketed = %q, want %q", got, c.want)
}
})
}
}
// TestAltFeedItemMatchesSPAShape locks the JSON contract with the SPA's
// AltFeedItem interface (AltFeedPanel.ts): the exact key names, and omitempty so
// an item with only a title carries no empty optional keys.
func TestAltFeedItemMatchesSPAShape(t *testing.T) {
full, _ := json.Marshal(altFeedItem{
Title: "T", Subtitle: "S", Price: "P", Href: "H", ImageURL: "I", Meta: "M",
})
for _, k := range []string{`"title"`, `"subtitle"`, `"price"`, `"href"`, `"imageUrl"`, `"meta"`} {
if !strings.Contains(string(full), k) {
t.Errorf("marshalled item missing key %s: %s", k, full)
}
}
min, _ := json.Marshal(altFeedItem{Title: "only"})
if string(min) != `{"title":"only"}` {
t.Errorf("omitempty broken: %s", min)
}
}
// TestAltFeedPendingShape: the cold-miss fallback carries an empty ARRAY (never
// null) so the SPA's Array.isArray/length check renders "live feed pending"
// instead of throwing, and never a fabricated row.
func TestAltFeedPendingShape(t *testing.T) {
rec := httptest.NewRecorder()
writeAltFeedPending(rec, "Christie's")
var out struct {
Items []altFeedItem `json:"items"`
Pending bool `json:"pending"`
Source string `json:"source"`
}
if err := json.Unmarshal(rec.Body.Bytes(), &out); err != nil {
t.Fatal(err)
}
if out.Items == nil || len(out.Items) != 0 {
t.Errorf("items must be empty array, got %#v", out.Items)
}
if !out.Pending || out.Source != "Christie's" {
t.Errorf("want pending+source, got %+v", out)
}
if !strings.Contains(rec.Body.String(), `"items":[]`) {
t.Errorf("items must serialize as [] not null: %s", rec.Body.String())
}
}
// TestParseGarbageNoPanic: malformed / empty input yields an empty slice, never
// a panic — the source can rot without taking the endpoint down.
func TestParseGarbageNoPanic(t *testing.T) {
for _, b := range [][]byte{nil, {}, []byte("not html"), []byte(`{"events": "oops"}`), []byte(`"propertiesList": [`)} {
if got := parseChristiesAuctions(b); len(got) != 0 {
t.Errorf("christies garbage → %d items", len(got))
}
if got := parseLuxuryEstateListings(b); len(got) != 0 {
t.Errorf("luxe garbage → %d items", len(got))
}
}
}
+16 -2
View File
@@ -32,6 +32,8 @@ const (
fredDEXCHUSURL = "https://api.stlouisfed.org/fred/series/observations?series_id=DEXCHUS&file_type=json&sort_order=desc&limit=2"
bisPolicyCacheKey = "economic:bis:policy:v1"
chinaMacroCacheKey = "china:macro:v1"
nbsCalendarIndexURL = "https://www.stats.gov.cn/english/PressRelease/ReleaseCalendar/"
chinaMoneyLPRURL = "https://www.chinamoney.com.cn/chinese/bklpr/?tab=2"
chinaMoneyLPRNoticeAPI = "https://www.chinamoney.com.cn/ags/ms/cm-s-notice-query/contentsinshorttime"
@@ -691,11 +693,18 @@ func (s *Server) handleChinaMacro(w http.ResponseWriter, r *http.Request) {
if preflight(w, r, "GET, OPTIONS") || methodNotGet(w, r) {
return
}
s.cachedJSON(w, "china:macro:v1",
s.cachedJSON(w, chinaMacroCacheKey,
"public, max-age=1800, s-maxage=1800, stale-while-revalidate=600",
30*time.Minute, 6*time.Hour,
func(ctx context.Context) (any, error) { return s.chinaMacro(ctx) },
func(w http.ResponseWriter, _ error) { writeJSON(w, http.StatusOK, "", chinaUnavailable()) })
func(w http.ResponseWriter, _ error) {
// Negative-cache the honest unavailable payload briefly (fresh, no stale
// window) so repeated cache-misses are served instantly instead of each
// re-hanging on the blocked upstream; it re-attempts once the TTL lapses.
v := chinaUnavailable()
s.cache.Set(chinaMacroCacheKey, v, negativeTTL, 0)
writeJSON(w, http.StatusOK, "", v)
})
}
// oecdHeaders carries the Accept-Language the OECD CLI endpoint requires (it
@@ -721,6 +730,11 @@ func chinaReasonFor(err error) string {
}
func (s *Server) chinaMacro(ctx context.Context) (any, error) {
// Fail fast: OECD → NBS → ChinaMoney are fetched sequentially and any one can
// be slow/blocked from prod. Cap the whole flow well under the caller's global
// deadline so a blocked upstream returns unavailable in ~9s, not ~24s.
ctx, cancel := context.WithTimeout(ctx, 9*time.Second)
defer cancel()
now := time.Now().UTC()
checkedAt := now.Format(time.RFC3339)
var decisions []chinaSourceDecision
+26 -9
View File
@@ -62,6 +62,13 @@ type fleetWorker struct {
VRAM string `json:"vram"`
Capabilities []string `json:"capabilities"`
Version string `json:"version"`
// Serving is the model ids this worker's hanzo-engine advertises (engine.serve),
// EngineStatus its reachability, JobQueue the gpu-jobs queue it claims from —
// the "what this GPU is serving" the fleet view surfaces. Empty when the worker
// runs only the studio.render job loop and no model server.
Serving []string `json:"serving"`
EngineStatus string `json:"engineStatus"`
JobQueue string `json:"jobQueue"`
}
type cloudFleet struct {
@@ -102,7 +109,7 @@ func (s *Server) handleCloudFleet(w http.ResponseWriter, r *http.Request) {
} `json:"machines"`
}
if err := s.getJSON(ctx, base+"/v1/machines", hdr, &machines); err != nil {
writeJSON(w, http.StatusOK, "", cloudFleet{Available: false, UpdatedAt: nowRFC(), Note: "Fleet inventory (visor) is unavailable right now."})
writeJSON(w, http.StatusOK, "private, no-store", cloudFleet{Available: false, UpdatedAt: nowRFC(), Note: "Fleet inventory (visor) is unavailable right now."})
return
}
var gpus struct {
@@ -114,8 +121,14 @@ func (s *Server) handleCloudFleet(w http.ResponseWriter, r *http.Request) {
var workers struct {
Workers []struct {
ID, Hostname, Provider, Location, Status, Version string
JobQueue string `json:"jobQueue"`
GPUs []struct{ Name, MemoryTotal string } `json:"gpus"`
Capabilities []string `json:"capabilities"`
Engine *struct {
URL string `json:"url"`
Models []string `json:"models"`
Status string `json:"status"`
} `json:"engine"`
} `json:"workers"`
}
_ = s.getJSON(ctx, base+"/v1/fleet/workers", hdr, &workers)
@@ -189,19 +202,23 @@ func (s *Server) handleCloudFleet(w http.ResponseWriter, r *http.Request) {
f.Totals.Regions = len(regionSet)
for _, wk := range workers.Workers {
fw := fleetWorker{ID: wk.ID, Hostname: wk.Hostname, Provider: wk.Provider, Location: wk.Location, Status: wk.Status, Version: wk.Version, Capabilities: wk.Capabilities}
fw := fleetWorker{ID: wk.ID, Hostname: wk.Hostname, Provider: wk.Provider, Location: wk.Location, Status: wk.Status, Version: wk.Version, Capabilities: wk.Capabilities, JobQueue: wk.JobQueue}
if len(wk.GPUs) > 0 {
fw.GPU = wk.GPUs[0].Name
if isRealVRAM(wk.GPUs[0].MemoryTotal) {
fw.VRAM = wk.GPUs[0].MemoryTotal
}
}
if wk.Engine != nil {
fw.Serving = wk.Engine.Models
fw.EngineStatus = wk.Engine.Status
}
f.Workers = append(f.Workers, fw)
}
f.Note = "Live fleet from visor (DO / GCP / AWS / BYO), grouped by provider and region."
f.UtilNote = "Live GPU utilization, memory-used and temperature are not yet instrumented in the fleet data plane — only inventory, status and (BYO) VRAM total are reported."
writeJSON(w, http.StatusOK, "", f)
writeJSON(w, http.StatusOK, "private, no-store", f)
}
func isRealVRAM(s string) bool {
@@ -282,7 +299,7 @@ func (s *Server) handleCloudServices(w http.ResponseWriter, r *http.Request) {
}
}
out.Note = "Per-subsystem health from o11y (live probe) fused with RED metrics over the last hour."
writeJSON(w, http.StatusOK, "", out)
writeJSON(w, http.StatusOK, "private, no-store", out)
}
// livenessURL maps a cloud subsystem to a real reachability endpoint, used to
@@ -449,7 +466,7 @@ func (s *Server) handleCloudAnalytics(w http.ResponseWriter, r *http.Request) {
} `json:"data"`
}
if err := s.getJSON(ctx, base+"/v1/analytics/websites", hdr, &sites); err != nil || len(sites.Data) == 0 {
writeJSON(w, http.StatusOK, "", cloudAnalytics{Available: false, UpdatedAt: nowRFC(), Window: "24h",
writeJSON(w, http.StatusOK, "private, no-store", cloudAnalytics{Available: false, UpdatedAt: nowRFC(), Window: "24h",
Note: "Web analytics (analytics.hanzo.ai) has no websites registered or is unreachable."})
return
}
@@ -512,7 +529,7 @@ func (s *Server) handleCloudAnalytics(w http.ResponseWriter, r *http.Request) {
out.TopReferrers = topMetrics(refs, 8)
out.TopCountries = topMetrics(countries, 8)
out.Note = "Live web analytics across all registered Hanzo sites (analytics.hanzo.ai), last 24h."
writeJSON(w, http.StatusOK, "", out)
writeJSON(w, http.StatusOK, "private, no-store", out)
}
func mergeMetric(ctx context.Context, s *Server, base, id, typ, q string, hdr map[string]string, into map[string]int64, mu *sync.Mutex) {
@@ -566,7 +583,7 @@ func (s *Server) handleCloudLLM(w http.ResponseWriter, r *http.Request) {
body, status, err := s.get(ctx, apiHost()+"/v1/admin/o11y?range="+rng, map[string]string{"Authorization": bearer})
if err != nil || status < 200 || status >= 300 {
writeJSON(w, http.StatusOK, "", map[string]any{
writeJSON(w, http.StatusOK, "private, no-store", map[string]any{
"available": false, "updatedAt": nowRFC(), "range": rng,
"note": "Platform LLM observability requires a cloud global-admin token; not available for this session.",
})
@@ -574,10 +591,10 @@ func (s *Server) handleCloudLLM(w http.ResponseWriter, r *http.Request) {
}
var payload map[string]any
if err := json.Unmarshal(body, &payload); err != nil {
writeJSON(w, http.StatusOK, "", map[string]any{"available": false, "range": rng, "note": "LLM observability response was unreadable."})
writeJSON(w, http.StatusOK, "private, no-store", map[string]any{"available": false, "range": rng, "note": "LLM observability response was unreadable."})
return
}
writeJSON(w, http.StatusOK, "", map[string]any{"available": true, "updatedAt": nowRFC(), "range": rng, "data": payload})
writeJSON(w, http.StatusOK, "private, no-store", map[string]any{"available": true, "updatedAt": nowRFC(), "range": rng, "data": payload})
}
// ── small shared helpers ─────────────────────────────────────────────────────
@@ -0,0 +1,432 @@
package world
import (
"context"
"encoding/json"
"net/http"
"sort"
"strings"
"sync"
"time"
)
// Admin-only Cloud infrastructure aggregates — the SuperAdmin fleet view's two
// panels that the fleet + services handlers (handlers_cloud_admin.go) did not yet
// cover: DOKS cluster NODES grouped by cluster, and the GPU JOB QUEUE (what is
// running, from which service). Both are gated by requireAdmin (fail-closed 403)
// and forward the caller's own IAM bearer to the cloud subsystems on api.hanzo.ai,
// which independently re-verify. Each degrades honestly to {available:false} on any
// upstream failure — never a 5xx or an invented number.
// ── clusters: DOKS cluster nodes grouped by cluster ──────────────────────────
//
// Real source: the unified k8s noun on api.hanzo.ai — /v1/k8s/clusters (the org's
// managed DOKS + BYO clusters) then, per cluster, /v1/k8s/clusters/{id} (node pools
// + worker nodes as machines). The fleet handler groups the SAME nodes by
// provider/region; this handler keeps them under their CLUSTER (hanzo-k8s, …) with
// per-cluster status and capacity, which the provider view flattens away.
type clusterNode struct {
ID string `json:"id"`
Name string `json:"name"`
Status string `json:"status"`
Type string `json:"type"`
Region string `json:"region"`
GPU string `json:"gpu"`
}
type clusterPool struct {
Name string `json:"name"`
Size string `json:"size"`
Count int `json:"count"`
AutoScale bool `json:"autoScale"`
MinNodes int `json:"minNodes"`
MaxNodes int `json:"maxNodes"`
}
type clusterGroup struct {
ID string `json:"id"`
Name string `json:"name"`
Region string `json:"region"`
Status string `json:"status"`
Kind string `json:"kind"` // managed (DOKS) | byo (attached kubeconfig)
Nodes int `json:"nodes"`
NodesReady int `json:"nodesReady"`
GPUs int `json:"gpus"`
Pools []clusterPool `json:"pools"`
NodeList []clusterNode `json:"nodeList"`
}
type clustersTotals struct {
Clusters int `json:"clusters"`
Nodes int `json:"nodes"`
NodesReady int `json:"nodesReady"`
GPUs int `json:"gpus"`
}
type cloudClusters struct {
Available bool `json:"available"`
UpdatedAt string `json:"updatedAt"`
Note string `json:"note"`
Totals clustersTotals `json:"totals"`
Clusters []clusterGroup `json:"clusters"`
}
// wireCluster mirrors cloud's clusterView (clients/visor/types.go): the list row.
type wireCluster struct {
DoksClusterID string `json:"doksClusterId"`
DoClusterID string `json:"doClusterId"`
Name string `json:"name"`
Region string `json:"region"`
Status string `json:"status"`
Kind string `json:"kind"`
NodeCount int `json:"nodeCount"`
NvidiaGPU int `json:"nvidiaGpu"`
AmdGPU int `json:"amdGpu"`
NodePools []struct {
Name string `json:"name"`
Size string `json:"size"`
Count int `json:"count"`
MinNodes int `json:"minNodes"`
MaxNodes int `json:"maxNodes"`
AutoScale bool `json:"autoScale"`
} `json:"nodePools"`
}
// wireClusterDetail mirrors cloud's clusterDetailView: the list row + worker nodes.
type wireClusterDetail struct {
wireCluster
Nodes []struct {
ID string `json:"id"`
Name string `json:"name"`
Region string `json:"region"`
Type string `json:"type"`
Status string `json:"status"`
GPU string `json:"gpu"`
} `json:"nodes"`
}
func (s *Server) handleCloudClusters(w http.ResponseWriter, r *http.Request) {
if preflight(w, r, "GET, OPTIONS") || methodNotGet(w, r) {
return
}
bearer, ok := s.requireAdmin(w, r)
if !ok {
return
}
ctx, cancel := context.WithTimeout(r.Context(), 22*time.Second)
defer cancel()
hdr := map[string]string{"Authorization": bearer}
base := apiHost()
var list struct {
Clusters []wireCluster `json:"clusters"`
}
if err := s.getJSON(ctx, base+"/v1/k8s/clusters", hdr, &list); err != nil {
writeJSON(w, http.StatusOK, "private, no-store", cloudClusters{Available: false, UpdatedAt: nowRFC(),
Note: "Kubernetes cluster inventory (visor) is unavailable right now."})
return
}
out := cloudClusters{Available: true, UpdatedAt: nowRFC()}
groups := make([]clusterGroup, len(list.Clusters))
var wg sync.WaitGroup
sem := make(chan struct{}, 6)
for i, kc := range list.Clusters {
wg.Add(1)
go func(i int, kc wireCluster) {
defer wg.Done()
sem <- struct{}{}
defer func() { <-sem }()
groups[i] = s.clusterGroup(ctx, base, hdr, kc)
}(i, kc)
}
wg.Wait()
for _, g := range groups {
out.Clusters = append(out.Clusters, g)
out.Totals.Clusters++
out.Totals.Nodes += g.Nodes
out.Totals.NodesReady += g.NodesReady
out.Totals.GPUs += g.GPUs
}
sort.Slice(out.Clusters, func(i, j int) bool {
if out.Clusters[i].Nodes == out.Clusters[j].Nodes {
return out.Clusters[i].Name < out.Clusters[j].Name
}
return out.Clusters[i].Nodes > out.Clusters[j].Nodes
})
out.Note = "Live DOKS + BYO clusters from visor, grouped by cluster with node pools and worker-node status."
writeJSON(w, http.StatusOK, "private, no-store", out)
}
// clusterGroup resolves one cluster to its group view. It fetches the cluster
// detail for node pools + worker nodes; a detail failure degrades to the list
// row's counts (never fabricated) so a single unreachable cluster never sinks the
// board.
func (s *Server) clusterGroup(ctx context.Context, base string, hdr map[string]string, kc wireCluster) clusterGroup {
g := clusterGroup{
ID: firstNonBlank(kc.DoksClusterID, kc.DoClusterID),
Name: orDash(kc.Name),
Region: kc.Region,
Status: orDash(kc.Status),
Kind: orDash(kc.Kind),
Nodes: kc.NodeCount,
GPUs: kc.NvidiaGPU + kc.AmdGPU,
}
for _, p := range kc.NodePools {
g.Pools = append(g.Pools, clusterPool{Name: p.Name, Size: p.Size, Count: p.Count,
AutoScale: p.AutoScale, MinNodes: p.MinNodes, MaxNodes: p.MaxNodes})
}
if g.ID == "" {
return g
}
var d wireClusterDetail
if err := s.getJSON(ctx, base+"/v1/k8s/clusters/"+g.ID, hdr, &d); err != nil {
return g
}
if len(d.NodePools) > 0 { // detail carries the authoritative pool set
g.Pools = g.Pools[:0]
for _, p := range d.NodePools {
g.Pools = append(g.Pools, clusterPool{Name: p.Name, Size: p.Size, Count: p.Count,
AutoScale: p.AutoScale, MinNodes: p.MinNodes, MaxNodes: p.MaxNodes})
}
}
if len(d.Nodes) > 0 {
g.Nodes = len(d.Nodes)
g.NodesReady = 0
for _, n := range d.Nodes {
g.NodeList = append(g.NodeList, clusterNode{ID: n.ID, Name: orDash(n.Name),
Status: n.Status, Type: n.Type, Region: n.Region, GPU: n.GPU})
if machineOnline(n.Status) {
g.NodesReady++
}
}
} else if g.Nodes > 0 {
g.NodesReady = g.Nodes // list row reports a count but no per-node status
}
return g
}
// ── queue: GPU job queue (gpu-jobs) — depth + what's running, from which service ─
//
// Real source: the tasks engine on api.hanzo.ai — GET
// /v1/tasks/namespaces/gpu-jobs/activities (the org's GPU work queue the
// `hanzo gpu connect` workers claim from) fused with /v1/fleet/workers (the online
// worker count). Job types are namespaced by service ("studio.render",
// "engine.serve"), so the dispatching service is the type's prefix. The queue is
// the caller's platform org — a SuperAdmin's bearer resolves to the admin org
// upstream, which owns the house-account + operator fleet.
const gpuJobsNamespace = "gpu-jobs"
type queueJob struct {
ID string `json:"id"`
Type string `json:"type"`
Service string `json:"service"`
Status string `json:"status"` // pending | running | done | failed | canceled
Worker string `json:"worker"`
Model string `json:"model"`
Attempt int `json:"attempt"`
StartedAt string `json:"startedAt"`
ClosedAt string `json:"closedAt"`
}
type queueService struct {
Service string `json:"service"`
Pending int `json:"pending"`
Running int `json:"running"`
}
type queueDepth struct {
Pending int `json:"pending"`
Running int `json:"running"`
Done int `json:"done"`
Failed int `json:"failed"`
Canceled int `json:"canceled"`
}
type cloudQueue struct {
Available bool `json:"available"`
UpdatedAt string `json:"updatedAt"`
Note string `json:"note"`
Namespace string `json:"namespace"`
Depth queueDepth `json:"depth"`
Workers struct {
Online int `json:"online"`
Total int `json:"total"`
} `json:"workers"`
Services []queueService `json:"services"`
Running []queueJob `json:"running"`
Pending []queueJob `json:"pending"`
Recent []queueJob `json:"recent"`
}
// wireActivity is the subset of the tasks engine's StandaloneActivity the queue
// view needs (pkg/tasks/types.go).
type wireActivity struct {
Execution struct {
WorkflowID string `json:"workflowId"`
} `json:"execution"`
Type struct {
Name string `json:"name"`
} `json:"type"`
Status string `json:"status"` // ACTIVITY_TASK_STATE_*
StartTime string `json:"startTime"`
CloseTime string `json:"closeTime"`
Identity string `json:"identity"`
Attempt int `json:"attempt"`
Input json.RawMessage `json:"input"`
}
func (s *Server) handleCloudQueue(w http.ResponseWriter, r *http.Request) {
if preflight(w, r, "GET, OPTIONS") || methodNotGet(w, r) {
return
}
bearer, ok := s.requireAdmin(w, r)
if !ok {
return
}
ctx, cancel := context.WithTimeout(r.Context(), 20*time.Second)
defer cancel()
hdr := map[string]string{"Authorization": bearer}
base := apiHost()
var acts struct {
Activities []wireActivity `json:"activities"`
}
if err := s.getJSON(ctx, base+"/v1/tasks/namespaces/"+gpuJobsNamespace+"/activities", hdr, &acts); err != nil {
writeJSON(w, http.StatusOK, "private, no-store", cloudQueue{Available: false, UpdatedAt: nowRFC(), Namespace: gpuJobsNamespace,
Note: "GPU job queue (tasks) is unavailable right now."})
return
}
out := cloudQueue{Available: true, UpdatedAt: nowRFC(), Namespace: gpuJobsNamespace}
svc := map[string]*queueService{}
var svcOrder []string
for _, a := range acts.Activities {
job := queueJob{
ID: a.Execution.WorkflowID,
Type: orDash(a.Type.Name),
Service: jobService(a.Type.Name),
Worker: a.Identity,
Model: jobModel(a.Input),
Attempt: a.Attempt,
StartedAt: a.StartTime,
ClosedAt: a.CloseTime,
}
switch a.Status {
case "ACTIVITY_TASK_STATE_SCHEDULED":
job.Status = "pending"
out.Depth.Pending++
out.Pending = append(out.Pending, job)
serviceBucket(svc, &svcOrder, job.Service).Pending++
case "ACTIVITY_TASK_STATE_STARTED":
job.Status = "running"
out.Depth.Running++
out.Running = append(out.Running, job)
serviceBucket(svc, &svcOrder, job.Service).Running++
case "ACTIVITY_TASK_STATE_COMPLETED":
job.Status = "done"
out.Depth.Done++
out.Recent = append(out.Recent, job)
case "ACTIVITY_TASK_STATE_FAILED":
job.Status = "failed"
out.Depth.Failed++
out.Recent = append(out.Recent, job)
case "ACTIVITY_TASK_STATE_CANCELED":
job.Status = "canceled"
out.Depth.Canceled++
out.Recent = append(out.Recent, job)
}
}
// Online worker count — the same BYO fleet the fleet panel lists. A read failure
// here leaves the worker count at zero; it never sinks the queue.
var workers struct {
Workers []struct {
Status string `json:"status"`
} `json:"workers"`
}
if err := s.getJSON(ctx, base+"/v1/fleet/workers", hdr, &workers); err == nil {
out.Workers.Total = len(workers.Workers)
for _, wk := range workers.Workers {
if wk.Status == "online" {
out.Workers.Online++
}
}
}
for _, name := range svcOrder {
out.Services = append(out.Services, *svc[name])
}
sort.Slice(out.Services, func(i, j int) bool {
a, b := out.Services[i], out.Services[j]
if a.Running+a.Pending == b.Running+b.Pending {
return a.Service < b.Service
}
return a.Running+a.Pending > b.Running+b.Pending
})
// Newest terminal first; keep the tail bounded so the panel stays a snapshot.
sort.Slice(out.Recent, func(i, j int) bool { return out.Recent[i].ClosedAt > out.Recent[j].ClosedAt })
if len(out.Recent) > 12 {
out.Recent = out.Recent[:12]
}
out.Note = "Live GPU job queue (gpu-jobs) — pending + running work by service, and what each worker is serving."
writeJSON(w, http.StatusOK, "private, no-store", out)
}
// jobService is the dispatching service of a job type: the segment before the
// first "." ("studio.render" → "studio", "engine.serve" → "engine"). A type with
// no dot is its own service; an empty type is "—".
func jobService(typ string) string {
typ = strings.TrimSpace(typ)
if typ == "" {
return "—"
}
if i := strings.IndexByte(typ, '.'); i > 0 {
return typ[:i]
}
return typ
}
// jobModel best-effort reads the target model from a job's input ("model", else a
// nested "input.model"). An absent field yields "" — never a guess.
func jobModel(raw json.RawMessage) string {
if len(raw) == 0 {
return ""
}
var m map[string]any
if err := json.Unmarshal(raw, &m); err != nil {
return ""
}
if v, ok := m["model"].(string); ok && v != "" {
return v
}
if inner, ok := m["input"].(map[string]any); ok {
if v, ok := inner["model"].(string); ok {
return v
}
}
return ""
}
func serviceBucket(m map[string]*queueService, order *[]string, name string) *queueService {
if b := m[name]; b != nil {
return b
}
b := &queueService{Service: name}
m[name] = b
*order = append(*order, name)
return b
}
func firstNonBlank(vals ...string) string {
for _, v := range vals {
if strings.TrimSpace(v) != "" {
return v
}
}
return ""
}
@@ -0,0 +1,196 @@
package world
import (
"encoding/json"
"net/http"
"net/http/httptest"
"testing"
)
// The SuperAdmin fleet view's two infrastructure aggregates — DOKS cluster nodes
// and the GPU job queue — must (1) fail-closed for a non-admin and (2) reshape the
// REAL upstream shapes (k8s clusters/detail + tasks activities) for an admin, never
// fabricating a number. These tests mock IAM userinfo (the admin gate) and the
// api.hanzo.ai subsystems (the data plane) so they run hermetically.
// adminUpstream is a mock api.hanzo.ai serving the exact routes the clusters +
// queue handlers fan out to, with real-shaped payloads.
func adminUpstream(t *testing.T) *httptest.Server {
t.Helper()
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
switch r.URL.Path {
case "/v1/k8s/clusters":
_, _ = w.Write([]byte(`{"clusters":[
{"doksClusterId":"c-hanzo","name":"hanzo-k8s","region":"sfo3","status":"running","kind":"managed","nodeCount":3,"nvidiaGpu":2},
{"doksClusterId":"c-adnexus","name":"adnexus-k8s","region":"sfo3","status":"running","kind":"managed","nodeCount":2}
]}`))
case "/v1/k8s/clusters/c-hanzo":
_, _ = w.Write([]byte(`{"doksClusterId":"c-hanzo","name":"hanzo-k8s","region":"sfo3","status":"running","kind":"managed",
"nodePools":[{"name":"pool-gpu","size":"gpu-l40","count":2,"autoScale":true,"minNodes":1,"maxNodes":4}],
"nodes":[
{"id":"n1","name":"hanzo-k8s-1","status":"active","type":"s-8vcpu-16gb","region":"sfo3"},
{"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"}
]}`))
case "/v1/k8s/clusters/c-adnexus":
_, _ = w.Write([]byte(`{"doksClusterId":"c-adnexus","name":"adnexus-k8s","region":"sfo3","status":"running","kind":"managed",
"nodes":[
{"id":"a1","name":"adnexus-k8s-1","status":"active","type":"s-4vcpu-8gb","region":"sfo3"},
{"id":"a2","name":"adnexus-k8s-2","status":"active","type":"s-4vcpu-8gb","region":"sfo3"}
]}`))
case "/v1/tasks/namespaces/gpu-jobs/activities":
_, _ = w.Write([]byte(`{"activities":[
{"execution":{"workflowId":"job-1"},"type":{"name":"studio.render"},"status":"ACTIVITY_TASK_STATE_STARTED","identity":"evo","startTime":"2026-07-21T10:00:00Z","input":{"model":"flux.1"}},
{"execution":{"workflowId":"job-2"},"type":{"name":"engine.serve"},"status":"ACTIVITY_TASK_STATE_STARTED","identity":"spark","startTime":"2026-07-21T10:01:00Z","input":{"model":"qwen3-32b"}},
{"execution":{"workflowId":"job-3"},"type":{"name":"studio.render"},"status":"ACTIVITY_TASK_STATE_SCHEDULED","input":{"model":"flux.1"}},
{"execution":{"workflowId":"job-4"},"type":{"name":"echo"},"status":"ACTIVITY_TASK_STATE_COMPLETED","closeTime":"2026-07-21T09:59:00Z"},
{"execution":{"workflowId":"job-5"},"type":{"name":"studio.render"},"status":"ACTIVITY_TASK_STATE_FAILED","closeTime":"2026-07-21T09:58:00Z"}
]}`))
case "/v1/fleet/workers":
_, _ = w.Write([]byte(`{"workers":[
{"id":"evo","hostname":"evo","status":"online"},
{"id":"spark","hostname":"spark","status":"online"},
{"id":"dbc","hostname":"dbc","status":"offline"}
]}`))
default:
w.WriteHeader(http.StatusNotFound)
}
}))
t.Cleanup(srv.Close)
return srv
}
// getInfra drives an admin-gated infra route through a mocked IAM + upstream.
func getInfra(t *testing.T, route string, iamStatus int, iamBody, bearer string) (*http.Response, []byte) {
t.Helper()
iam := iamStub(t, iamStatus, iamBody)
t.Setenv("HANZO_IAM_ISSUER", iam.URL)
t.Setenv("HANZO_API_BASE", adminUpstream(t).URL)
s := NewServer()
mux := http.NewServeMux()
s.Mount(mux)
ts := httptest.NewServer(mux)
t.Cleanup(ts.Close)
req, _ := http.NewRequest(http.MethodGet, ts.URL+route, nil)
if bearer != "" {
req.Header.Set("Authorization", bearer)
}
resp, err := http.DefaultClient.Do(req)
if err != nil {
t.Fatalf("request: %v", err)
}
t.Cleanup(func() { resp.Body.Close() })
var raw json.RawMessage
_ = json.NewDecoder(resp.Body).Decode(&raw)
return resp, raw
}
// TestCloudClustersAdmin: an admin owner receives the real clusters, grouped by
// cluster with node pools + worker-node status, and honest ready/total counts.
func TestCloudClustersAdmin(t *testing.T) {
resp, body := getInfra(t, "/v1/world/cloud/clusters", 200, `{"owner":"admin","sub":"z"}`, "Bearer good")
if resp.StatusCode != http.StatusOK {
t.Fatalf("admin got %d, want 200 (body=%s)", resp.StatusCode, body)
}
if cache := resp.Header.Get("Cache-Control"); cache != "private, no-store" {
t.Fatalf("admin cluster read must be no-store, got %q", cache)
}
var cc cloudClusters
if err := json.Unmarshal(body, &cc); err != nil {
t.Fatalf("decode: %v", err)
}
if !cc.Available {
t.Fatalf("want available")
}
if cc.Totals.Clusters != 2 {
t.Fatalf("want 2 clusters, got %d", cc.Totals.Clusters)
}
// hanzo-k8s sorts first (3 nodes > 2). Detail replaced the count-only row.
h := cc.Clusters[0]
if h.Name != "hanzo-k8s" {
t.Fatalf("want hanzo-k8s first, got %q", h.Name)
}
if h.Nodes != 3 || h.NodesReady != 2 { // n3 is provisioning, not ready
t.Fatalf("hanzo-k8s nodes=%d ready=%d, want 3/2", h.Nodes, h.NodesReady)
}
if len(h.Pools) != 1 || h.Pools[0].Size != "gpu-l40" || !h.Pools[0].AutoScale {
t.Fatalf("hanzo-k8s pools not surfaced: %+v", h.Pools)
}
if h.GPUs != 2 {
t.Fatalf("hanzo-k8s GPUs=%d, want 2", h.GPUs)
}
if cc.Totals.Nodes != 5 || cc.Totals.NodesReady != 4 {
t.Fatalf("totals nodes=%d ready=%d, want 5/4", cc.Totals.Nodes, cc.Totals.NodesReady)
}
}
// TestCloudQueueAdmin: an admin owner receives the real gpu-jobs queue — depth by
// status, running/pending jobs with worker + model + dispatching service, and the
// online BYO worker count.
func TestCloudQueueAdmin(t *testing.T) {
resp, body := getInfra(t, "/v1/world/cloud/queue", 200, `{"owner":"admin","sub":"z"}`, "Bearer good")
if resp.StatusCode != http.StatusOK {
t.Fatalf("admin got %d, want 200 (body=%s)", resp.StatusCode, body)
}
if cache := resp.Header.Get("Cache-Control"); cache != "private, no-store" {
t.Fatalf("admin queue read must be no-store, got %q", cache)
}
var q cloudQueue
if err := json.Unmarshal(body, &q); err != nil {
t.Fatalf("decode: %v", err)
}
if !q.Available || q.Namespace != "gpu-jobs" {
t.Fatalf("want available gpu-jobs, got %+v", q)
}
if q.Depth.Running != 2 || q.Depth.Pending != 1 || q.Depth.Done != 1 || q.Depth.Failed != 1 {
t.Fatalf("depth wrong: %+v", q.Depth)
}
if q.Workers.Online != 2 || q.Workers.Total != 3 {
t.Fatalf("workers online=%d total=%d, want 2/3", q.Workers.Online, q.Workers.Total)
}
// The dispatching service is the job type's prefix; studio has the render + one
// pending, engine has the serve.
svc := map[string]queueService{}
for _, s := range q.Services {
svc[s.Service] = s
}
if svc["studio"].Running != 1 || svc["studio"].Pending != 1 {
t.Fatalf("studio service wrong: %+v", svc["studio"])
}
if svc["engine"].Running != 1 {
t.Fatalf("engine service wrong: %+v", svc["engine"])
}
// Running jobs carry the claiming worker + the target model.
var served string
for _, j := range q.Running {
if j.Worker == "spark" {
served = j.Model
}
}
if served != "qwen3-32b" {
t.Fatalf("want spark serving qwen3-32b, got %q", served)
}
}
// TestCloudInfraGate: both routes fail-closed for a non-admin owner (403) and
// anonymous (401), leaking no payload.
func TestCloudInfraGate(t *testing.T) {
for _, route := range []string{"/v1/world/cloud/clusters", "/v1/world/cloud/queue"} {
resp, body := getInfra(t, route, 200, `{"owner":"acme","sub":"u1"}`, "Bearer good")
if resp.StatusCode != http.StatusForbidden {
t.Fatalf("%s non-admin got %d, want 403", route, resp.StatusCode)
}
var probe map[string]any
if json.Unmarshal(body, &probe) == nil {
if _, leaked := probe["clusters"]; leaked {
t.Fatalf("%s leaked clusters to non-admin", route)
}
if _, leaked := probe["depth"]; leaked {
t.Fatalf("%s leaked queue depth to non-admin", route)
}
}
}
}
+3 -1
View File
@@ -26,6 +26,8 @@ func TestCloudAdminGateFailsClosed(t *testing.T) {
"/v1/world/cloud/services",
"/v1/world/cloud/analytics",
"/v1/world/cloud/llm",
"/v1/world/cloud/clusters",
"/v1/world/cloud/queue",
}
for _, route := range adminRoutes {
t.Run(route, func(t *testing.T) {
@@ -48,7 +50,7 @@ func TestCloudAdminGateFailsClosed(t *testing.T) {
t.Fatalf("expected an error field, got %v", body)
}
// The aggregate payload keys must NOT be present in a gated response.
for _, leak := range []string{"providers", "services", "topPages", "data", "workers"} {
for _, leak := range []string{"providers", "services", "topPages", "data", "workers", "clusters", "depth", "running"} {
if _, ok := body[leak]; ok {
t.Fatalf("gated response leaked %q: %v", leak, body)
}
@@ -127,6 +127,7 @@ func TestBYOGPUAdminReal(t *testing.T) {
t.Setenv("HANZO_CLOUD_PULSE_TOKEN", "") // no service token — the admin's bearer drives it
t.Setenv("HANZO_API_BASE", up.URL)
t.Setenv("HANZO_IAM_ISSUER", iam.URL)
t.Setenv("WORLD_ADMIN_ORGS", "hanzo") // operator org resolves via deploy env, not code
req, _ := http.NewRequest(http.MethodGet, serveWorld(t)+"/v1/world/cloud/byo-gpu", nil)
req.Header.Set("Authorization", "Bearer admin-token")
+2
View File
@@ -315,6 +315,7 @@ func TestCloudPulseAdminBearer(t *testing.T) {
t.Setenv("HANZO_API_BASE", up.URL)
t.Setenv("HANZO_STATUS_BASE", up.URL)
t.Setenv("HANZO_IAM_ISSUER", iam.URL)
t.Setenv("WORLD_ADMIN_ORGS", "hanzo") // operator org resolves via deploy env, not code
s := NewServer()
mux := http.NewServeMux()
@@ -405,6 +406,7 @@ func TestCloudPulseAdminLLMObservability(t *testing.T) {
t.Setenv("HANZO_API_BASE", up.URL)
t.Setenv("HANZO_STATUS_BASE", up.URL)
t.Setenv("HANZO_IAM_ISSUER", iam.URL)
t.Setenv("WORLD_ADMIN_ORGS", "hanzo") // operator org resolves via deploy env, not code
s := NewServer()
mux := http.NewServeMux()
+145
View File
@@ -0,0 +1,145 @@
package world
import (
"context"
"encoding/json"
"net/http"
"strings"
"time"
)
// Congressional trading — the disclosed stock transactions of U.S. House and
// Senate members (STOCK Act filings). The "smart money in Congress" macro signal:
// who is buying and selling, and the net direction. Every free public feed for
// this data is now gated behind a key (the house/senate-stock-watcher S3 buckets
// went private, Capitol Trades' BFF is unstable), so this endpoint reads Quiver
// Quant's live feed when QUIVER_API_KEY is configured and degrades to a clean
// unavailable payload otherwise — the same key-gated pattern as the finnhub, eia
// and acled endpoints. The parser is real and tested; only the fetch needs a key.
const quiverCongressURL = "https://api.quiverquant.com/beta/live/congresstrading"
// handleCongress serves /v1/world/congress.
func (s *Server) handleCongress(w http.ResponseWriter, r *http.Request) {
if preflight(w, r, "GET, OPTIONS") || methodNotGet(w, r) {
return
}
s.cachedJSON(w, "congress:v1",
"public, max-age=3600, s-maxage=3600, stale-while-revalidate=7200",
time.Hour, 2*time.Hour,
func(ctx context.Context) (any, error) { return s.computeCongress(ctx) },
func(w http.ResponseWriter, err error) {
reason := "temporarily unavailable"
if err == errMissingKey {
reason = "QUIVER_API_KEY not configured"
}
writeJSON(w, http.StatusOK, "", map[string]any{
"asOf": nowISO(), "unavailable": true, "reason": reason,
"count": 0, "buys": 0, "sells": 0, "recent": []any{},
})
})
}
func (s *Server) computeCongress(ctx context.Context) (any, error) {
key := env("QUIVER_API_KEY", "QUIVERQUANT_API_KEY")
if key == "" {
return nil, errMissingKey
}
body, status, err := s.get(ctx, quiverCongressURL, map[string]string{
"Accept": "application/json", "Authorization": "Bearer " + key,
})
if err != nil {
return nil, err
}
if status < 200 || status >= 300 {
return nil, errUnavailable
}
trades := parseCongressTrades(body)
buys, sells := 0, 0
recent := make([]map[string]any, 0, 40)
for i, t := range trades {
switch t.Side {
case "buy":
buys++
case "sell":
sells++
}
if i < 40 {
recent = append(recent, map[string]any{
"member": t.Member, "ticker": t.Ticker, "side": t.Side,
"amount": t.Range, "chamber": t.Chamber,
"tradedAt": t.TradedAt, "reportedAt": t.ReportedAt,
})
}
}
return map[string]any{
"asOf": nowISO(),
"source": "Quiver Quant · Congressional trading",
"count": len(trades),
"buys": buys,
"sells": sells,
"recent": recent,
}, nil
}
// errMissingKey signals an absent API key so the handler can degrade with a
// precise reason rather than a generic "unavailable". Package-private, never
// serialized.
var errMissingKey = &rotationError{"api key not configured"}
// ── pure parsing (unit-tested) ───────────────────────────────────────────────
type congressTrade struct {
Member string
Ticker string
Side string // "buy" | "sell" | ""
Range string
Chamber string
TradedAt string
ReportedAt string
}
// parseCongressTrades reads Quiver Quant's live congresstrading JSON array.
// Malformed input yields an empty slice, never a panic. Field names follow
// Quiver's schema; the transaction is normalized to buy/sell.
func parseCongressTrades(body []byte) []congressTrade {
var raw []struct {
Representative string `json:"Representative"`
Ticker string `json:"Ticker"`
Transaction string `json:"Transaction"`
Range string `json:"Range"`
House string `json:"House"`
TransactionDate string `json:"TransactionDate"`
ReportDate string `json:"ReportDate"`
}
if err := json.Unmarshal(body, &raw); err != nil {
return nil
}
out := make([]congressTrade, 0, len(raw))
for _, r := range raw {
out = append(out, congressTrade{
Member: strings.TrimSpace(r.Representative),
Ticker: strings.TrimSpace(r.Ticker),
Side: normalizeTradeSide(r.Transaction),
Range: strings.TrimSpace(r.Range),
Chamber: strings.TrimSpace(r.House),
TradedAt: warnDate(r.TransactionDate),
ReportedAt: warnDate(r.ReportDate),
})
}
return out
}
// normalizeTradeSide maps Quiver's transaction labels ("Purchase", "Sale",
// "Sale (Partial)", "Sale (Full)", "Exchange") to buy/sell. Unknown → "".
func normalizeTradeSide(t string) string {
t = strings.ToLower(strings.TrimSpace(t))
switch {
case strings.Contains(t, "purchase"), strings.Contains(t, "buy"):
return "buy"
case strings.Contains(t, "sale"), strings.Contains(t, "sell"):
return "sell"
default:
return ""
}
}
+52
View File
@@ -0,0 +1,52 @@
package world
import "testing"
func TestNormalizeTradeSide(t *testing.T) {
cases := []struct{ in, want string }{
{"Purchase", "buy"},
{"Sale (Partial)", "sell"},
{"Sale (Full)", "sell"},
{"sale", "sell"},
{"buy", "buy"},
{"Exchange", ""},
{"", ""},
}
for _, c := range cases {
if got := normalizeTradeSide(c.in); got != c.want {
t.Errorf("normalizeTradeSide(%q) = %q, want %q", c.in, got, c.want)
}
}
}
func TestParseCongressTradesMalformed(t *testing.T) {
if got := parseCongressTrades([]byte("not json <<<")); got != nil {
t.Errorf("malformed: want nil, got %v", got)
}
if got := parseCongressTrades(nil); got != nil {
t.Errorf("nil: want nil, got %v", got)
}
if got := parseCongressTrades([]byte("[]")); len(got) != 0 {
t.Errorf("empty: want 0, got %d", len(got))
}
}
func TestParseCongressTradesWellFormed(t *testing.T) {
body := []byte(`[
{"Representative":"Nancy Pelosi","Ticker":"NVDA","Transaction":"Purchase","Range":"$1,000,001 - $5,000,000","House":"Representatives","TransactionDate":"2026-06-20T00:00:00.000","ReportDate":"2026-07-01T00:00:00.000"},
{"Representative":" Tommy Tuberville ","Ticker":"XOM","Transaction":"Sale (Full)","Range":"$15,001 - $50,000","House":"Senate","TransactionDate":"2026-06-18","ReportDate":"2026-06-28"}
]`)
got := parseCongressTrades(body)
if len(got) != 2 {
t.Fatalf("want 2 trades, got %d", len(got))
}
if got[0].Member != "Nancy Pelosi" || got[0].Ticker != "NVDA" || got[0].Side != "buy" {
t.Errorf("trade[0] = %+v", got[0])
}
if got[0].Chamber != "Representatives" || got[0].TradedAt != "2026-06-20" || got[0].ReportedAt != "2026-07-01" {
t.Errorf("trade[0] meta = %+v", got[0])
}
if got[1].Member != "Tommy Tuberville" || got[1].Side != "sell" { // trimmed + sell
t.Errorf("trade[1] = %+v", got[1])
}
}
+68 -10
View File
@@ -1,13 +1,17 @@
package world
import (
"context"
"encoding/json"
"io"
"net/http"
"time"
"github.com/hanzoai/world/internal/world/store"
)
// Per-identity opaque JSON blobs — the ONE mechanism behind both the composed
// DASHBOARD and the user's USAGE HISTORY.
// Per-identity opaque JSON blobs — the ONE mechanism behind the composed
// DASHBOARD, its ORG-SHARED default, and the user's USAGE HISTORY.
//
// The AI analyst and the toolbar compose the dashboard on the fly (add/remove
// widgets and data sources, rearrange panels, create custom feed panels), and the
@@ -17,14 +21,23 @@ import (
// Anonymous callers get 401 and keep their localStorage-only state; nothing about
// the signed-out experience changes.
//
// A dashboard has TWO scopes over the SAME opaque-blob contract:
// - PER-USER (/v1/world/dashboard) — the signed-in user's own layout.
// - ORG-SHARED (/v1/world/dashboard/shared) — the default an org ADMIN publishes
// for the whole org. Every member READS it; only an admin PUTs it. The frontend
// hydrates the org default first, then overlays the user's own doc (user wins).
//
// ONE store, namespaced: this reuses the SAME per-identity settings store that
// monitors already use (store.Settings), under a `project` namespace per concern —
// there is no second table. The blob is an OPAQUE JSON object (a verbatim mirror of
// the client's localStorage keys). The backend validates it is a JSON object at the
// boundary and never interprets it. It holds layout / usage state only — NEVER secrets.
// there is no second table. The org-shared doc is the same 'dashboard' project keyed
// by the org (store.SharedSub) instead of a user. The blob is an OPAQUE JSON object
// (a verbatim mirror of the client's localStorage keys). The backend validates it is
// a JSON object at the boundary and never interprets it. It holds layout / usage
// state only — NEVER secrets.
//
// GET /v1/world/dashboard → { config: {...} } · PUT → { ok: true } (body: the object)
// GET /v1/world/history → { config: {...} } · PUT → { ok: true } (body: the object)
// GET /v1/world/dashboard → { config: {...} } · PUT → { ok: true } (body: the object)
// GET /v1/world/dashboard/shared → { config: {...} } · PUT → { ok: true } (admin-only; 403 otherwise)
// GET /v1/world/history → { config: {...} } · PUT → { ok: true } (body: the object)
const dashboardDoc = "dashboard" // per-identity store namespace: composed dashboard
@@ -35,10 +48,46 @@ func (s *Server) handleDashboard(w http.ResponseWriter, r *http.Request) {
s.handleIdentityBlob(w, r, dashboardDoc)
}
// handleDashboardShared serves the ORG-SHARED dashboard default: the layout an org
// admin publishes for everyone in the org. GET returns the org's published blob to
// any signed-in member (empty until first publish); PUT publishes it and is
// ADMIN-ONLY (403 otherwise). It is the SAME opaque-blob contract as the per-user
// dashboard, keyed by the org (store.SharedSub) instead of the user. The server is
// the authority on who may publish — the client only hides the trigger.
func (s *Server) handleDashboardShared(w http.ResponseWriter, r *http.Request) {
setCORS(w, "GET, PUT, OPTIONS")
if r.Method == http.MethodOptions {
w.WriteHeader(http.StatusNoContent)
return
}
if r.Method != http.MethodGet && r.Method != http.MethodPut {
writeError(w, http.StatusMethodNotAllowed, "GET or PUT")
return
}
bearer := userBearer(r)
if bearer == "" {
writeError(w, http.StatusUnauthorized, "Sign in required")
return
}
ctx, cancel := context.WithTimeout(r.Context(), 10*time.Second)
defer cancel()
id, err := s.introspectIdentity(ctx, bearer)
if err != nil || id.Sub == "" {
writeError(w, http.StatusUnauthorized, "Sign in required")
return
}
// Reading the org default is open to every signed-in member; PUBLISHING it is
// admin-only.
if r.Method == http.MethodPut && !s.isOrgAdmin(id) {
writeError(w, http.StatusForbidden, "Admin only — publish the org default")
return
}
s.serveIdentityBlob(w, r, store.Identity{Org: id.Org, UserSub: store.SharedSub, Project: dashboardDoc})
}
// handleIdentityBlob serves GET (read this identity's blob under `doc`) and PUT
// (upsert it), both bearer-gated. Body + response are an opaque JSON object under
// "config". Never 5xx: a degraded store returns {} on GET and ok:false on PUT so the
// client falls back to localStorage.
// (upsert it), both bearer-gated. It resolves the PER-USER identity and delegates
// the read/write body to serveIdentityBlob.
func (s *Server) handleIdentityBlob(w http.ResponseWriter, r *http.Request, doc string) {
setCORS(w, "GET, PUT, OPTIONS")
if r.Method == http.MethodOptions {
@@ -53,7 +102,16 @@ func (s *Server) handleIdentityBlob(w http.ResponseWriter, r *http.Request, doc
if !ok {
return
}
s.serveIdentityBlob(w, r, ident)
}
// serveIdentityBlob is the ONE GET/PUT body over an opaque JSON object under
// "config": GET returns the stored blob (or {}), PUT validates a JSON object at the
// boundary and upserts it verbatim. The caller resolves the identity (per-user or
// org-shared) and enforces any write gate FIRST — this function is scope-agnostic.
// Never 5xx: a degraded store returns {} on GET and ok:false on PUT so the client
// falls back to localStorage.
func (s *Server) serveIdentityBlob(w http.ResponseWriter, r *http.Request, ident store.Identity) {
if r.Method == http.MethodGet {
blob, ok := s.store.Settings.Get(ident)
if !ok {
+111 -4
View File
@@ -4,6 +4,7 @@ import (
"crypto/sha256"
"encoding/hex"
"encoding/json"
"net/http"
"net/http/httptest"
"reflect"
"strings"
@@ -32,8 +33,24 @@ func seedIdentity(s *Server, bearer, org, sub string) {
s.cache.Set(key, wIdentity{Org: org, Sub: sub}, time.Minute, time.Minute)
}
// seedAdmin primes an identity IAM marks as an admin owner (isAdmin=true), so it
// may publish its org's shared doc even when the org is not a global-admin org.
func seedAdmin(s *Server, bearer, org, sub string) {
sum := sha256.Sum256([]byte(bearer))
key := "identity:" + hex.EncodeToString(sum[:12])
s.cache.Set(key, wIdentity{Org: org, Sub: sub, Admin: true}, time.Minute, time.Minute)
}
func callDashboard(s *Server, method, bearer, body string) *httptest.ResponseRecorder {
r := httptest.NewRequest(method, "/v1/world/dashboard", strings.NewReader(body))
return callPath(s, s.handleDashboard, "/v1/world/dashboard", method, bearer, body)
}
func callDashboardShared(s *Server, method, bearer, body string) *httptest.ResponseRecorder {
return callPath(s, s.handleDashboardShared, "/v1/world/dashboard/shared", method, bearer, body)
}
func callPath(s *Server, h http.HandlerFunc, path, method, bearer, body string) *httptest.ResponseRecorder {
r := httptest.NewRequest(method, path, strings.NewReader(body))
if bearer != "" {
r.Header.Set("Authorization", bearer)
}
@@ -41,7 +58,7 @@ func callDashboard(s *Server, method, bearer, body string) *httptest.ResponseRec
r.Header.Set("Content-Type", "application/json")
}
w := httptest.NewRecorder()
s.handleDashboard(w, r)
h(w, r)
return w
}
@@ -85,8 +102,8 @@ func TestDashboardRoundTripAndIdentityIsolation(t *testing.T) {
// GET returns EXACTLY what was stored, verbatim.
want := map[string]string{
"panel-order": `["live-news","markets"]`,
"worldmonitor-panels": `{"markets":{"enabled":true}}`,
"panel-order": `["live-news","markets"]`,
"worldmonitor-panels": `{"markets":{"enabled":true}}`,
"hanzo-world-map-mode": "3d",
}
if got := dashboardConfig(t, callDashboard(s, "GET", alice, "")); !reflect.DeepEqual(got, want) {
@@ -132,3 +149,93 @@ func TestDashboardRejectsAnonAndNonObject(t *testing.T) {
t.Fatalf("DELETE status = %d, want 405", w.Code)
}
}
// The org-shared dashboard is the ONE org default an admin publishes for the whole
// org. The invariants that MUST hold:
// 1. an admin PUBLISHES it and it round-trips (PUT then GET returns it verbatim),
// 2. every signed-in MEMBER of that org may READ it,
// 3. a NON-admin member may NOT publish it (403), leaving it unchanged,
// 4. publishing the shared doc NEVER touches any user's per-user dashboard,
// 5. it is isolated per org (another org sees NONE of it),
// 6. anonymous callers are refused (401).
func TestDashboardSharedOrgDefault(t *testing.T) {
s := testServer(t)
const admin, member, outsider = "Bearer admin-tok", "Bearer member-tok", "Bearer outsider-tok"
seedAdmin(s, admin, "acme", "admin-user") // org admin (isAdmin) of acme
seedIdentity(s, member, "acme", "member-user") // ordinary acme member
seedIdentity(s, outsider, "other", "other-user") // a different org entirely
// Nothing published yet → a member's GET is an empty object.
if got := dashboardConfig(t, callDashboardShared(s, "GET", member, "")); len(got) != 0 {
t.Fatalf("shared GET before publish = %v, want {}", got)
}
// A non-admin member may NOT publish — 403, and nothing is stored.
if w := callDashboardShared(s, "PUT", member, `{"panel-order":"[\"news\"]"}`); w.Code != 403 {
t.Fatalf("non-admin publish status = %d, want 403", w.Code)
}
if got := dashboardConfig(t, callDashboardShared(s, "GET", member, "")); len(got) != 0 {
t.Fatalf("shared doc changed after refused publish: %v", got)
}
// The admin publishes the org default.
shared := `{"panel-order":"[\"markets\",\"live-news\"]","hanzo-world-map-mode":"2d"}`
if w := callDashboardShared(s, "PUT", admin, shared); w.Code != 200 {
t.Fatalf("admin publish status = %d (body=%s)", w.Code, w.Body.String())
} else {
var put struct {
OK bool `json:"ok"`
}
if json.Unmarshal(w.Body.Bytes(), &put); !put.OK {
t.Fatalf("admin publish ok = false (body=%s)", w.Body.String())
}
}
// Every member of the org reads EXACTLY what the admin published.
want := map[string]string{
"panel-order": `["markets","live-news"]`,
"hanzo-world-map-mode": "2d",
}
if got := dashboardConfig(t, callDashboardShared(s, "GET", member, "")); !reflect.DeepEqual(got, want) {
t.Fatalf("member shared GET = %v, want %v", got, want)
}
if got := dashboardConfig(t, callDashboardShared(s, "GET", admin, "")); !reflect.DeepEqual(got, want) {
t.Fatalf("admin shared GET = %v, want %v", got, want)
}
// Publishing the shared doc must NOT create/alter any per-user dashboard.
if got := dashboardConfig(t, callDashboard(s, "GET", member, "")); len(got) != 0 {
t.Fatalf("shared publish leaked into member's per-user dashboard: %v", got)
}
if got := dashboardConfig(t, callDashboard(s, "GET", admin, "")); len(got) != 0 {
t.Fatalf("shared publish leaked into admin's per-user dashboard: %v", got)
}
// Org isolation: a different org's shared default is untouched (empty).
if got := dashboardConfig(t, callDashboardShared(s, "GET", outsider, "")); len(got) != 0 {
t.Fatalf("org leak: other org sees %v", got)
}
// Anonymous callers are refused on both verbs.
if w := callDashboardShared(s, "GET", "", ""); w.Code != 401 {
t.Fatalf("anon shared GET status = %d, want 401", w.Code)
}
if w := callDashboardShared(s, "PUT", "", shared); w.Code != 401 {
t.Fatalf("anon shared PUT status = %d, want 401", w.Code)
}
}
// A global-admin ORG (WORLD_ADMIN_ORGS / the base {admin,built-in}) may publish
// even without the per-identity isAdmin flag — the second half of isOrgAdmin.
func TestDashboardSharedAdminOrgMayPublish(t *testing.T) {
s := testServer(t)
const op = "Bearer op-tok"
seedIdentity(s, op, "admin", "op-user") // owner is the base admin org
if w := callDashboardShared(s, "PUT", op, `{"hanzo-world-map-mode":"3d"}`); w.Code != 200 {
t.Fatalf("admin-org publish status = %d (body=%s)", w.Code, w.Body.String())
}
if got := dashboardConfig(t, callDashboardShared(s, "GET", op, "")); !reflect.DeepEqual(got, map[string]string{"hanzo-world-map-mode": "3d"}) {
t.Fatalf("admin-org shared GET = %v", got)
}
}
+121
View File
@@ -0,0 +1,121 @@
package world
import (
"context"
"net/http"
"sort"
"sync"
"time"
)
// DeFi snapshot from DeFiLlama's public API (no key): total value locked across
// chains, the largest chains, and the stablecoin float. One cached endpoint so
// every viewer shares one upstream pull. Degrades to a clean unavailable 200.
// handleDefi serves /v1/world/defi.
func (s *Server) handleDefi(w http.ResponseWriter, r *http.Request) {
if preflight(w, r, "GET, OPTIONS") || methodNotGet(w, r) {
return
}
s.cachedJSON(w, "defi:v1",
"public, max-age=900, s-maxage=900, stale-while-revalidate=1800",
15*time.Minute, 30*time.Minute,
func(ctx context.Context) (any, error) { return s.computeDefi(ctx) },
func(w http.ResponseWriter, err error) {
writeJSON(w, http.StatusOK, "", map[string]any{
"asOf": nowISO(), "unavailable": true,
"totalTvl": 0, "chains": []any{}, "stablecoins": map[string]any{},
})
})
}
func (s *Server) computeDefi(ctx context.Context) (any, error) {
var (
wg sync.WaitGroup
chains []defiChain
stableTotal float64
stableTop []map[string]any
stableErr error
chainErr error
)
wg.Add(2)
go func() {
defer wg.Done()
var raw []defiChain
if err := s.getJSON(ctx, "https://api.llama.fi/v2/chains",
map[string]string{"Accept": "application/json", "User-Agent": browserUA}, &raw); err != nil {
chainErr = err
return
}
chains = raw
}()
go func() {
defer wg.Done()
var sb struct {
PeggedAssets []struct {
Name string `json:"name"`
Symbol string `json:"symbol"`
Circulating map[string]float64 `json:"circulating"`
} `json:"peggedAssets"`
}
if err := s.getJSON(ctx, "https://stablecoins.llama.fi/stablecoins?includePrices=false",
map[string]string{"Accept": "application/json", "User-Agent": browserUA}, &sb); err != nil {
stableErr = err
return
}
type sc struct {
name, symbol string
mc float64
}
var all []sc
for _, p := range sb.PeggedAssets {
mc := p.Circulating["peggedUSD"]
stableTotal += mc
all = append(all, sc{p.Name, p.Symbol, mc})
}
sort.Slice(all, func(i, j int) bool { return all[i].mc > all[j].mc })
for i, c := range all {
if i >= 6 {
break
}
stableTop = append(stableTop, map[string]any{"name": c.name, "symbol": c.symbol, "marketCap": round2s(c.mc)})
}
}()
wg.Wait()
if chainErr != nil && stableErr != nil {
return nil, chainErr
}
var total float64
for _, c := range chains {
total += c.Tvl
}
sort.Slice(chains, func(i, j int) bool { return chains[i].Tvl > chains[j].Tvl })
top := make([]map[string]any, 0, 10)
for i, c := range chains {
if i >= 10 {
break
}
share := 0.0
if total > 0 {
share = c.Tvl / total * 100
}
top = append(top, map[string]any{"name": c.Name, "tvl": round2s(c.Tvl), "share": round2s(share)})
}
return map[string]any{
"asOf": nowISO(),
"totalTvl": round2s(total),
"chains": top,
"stablecoins": map[string]any{
"totalCirculating": round2s(stableTotal),
"top": stableTop,
},
}, nil
}
type defiChain struct {
Name string `json:"name"`
Tvl float64 `json:"tvl"`
}
+4 -4
View File
@@ -15,10 +15,10 @@ import (
// Enso flywheel — the router's self-improvement loop, made visible for the AI
// variant. It folds THREE honest sources into one event-typed payload:
//
// 1. the routing-decision ledger tail (export-routing-ledger ?since=, super-admin
// 1. the routing-decision ledger tail (router/ledger ?since=, super-admin
// JSONL): how many decisions, the engine-vs-heuristic mix, a confidence
// histogram, and the task / routed-model distribution;
// 2. the reward tail (export-routing-rewards ?since=, JSONL): how many decisions
// 2. the reward tail (router/rewards ?since=, JSONL): how many decisions
// have been scored and their mean reward — the per-request training labels;
// 3. the latest enso-bench eval scores (an embedded snapshot of the enso-bench
// results summary, or a live ENSO_BENCH_URL when configured).
@@ -181,7 +181,7 @@ func (s *Server) foldRoutingLedger(ctx context.Context, since string) (ensoLedge
return ensoLedgerStats{Available: false}, false
}
host := apiHost()
body, err := s.getText(ctx, host+"/v1/export-routing-ledger?since="+url.QueryEscape(since), hdr)
body, err := s.getText(ctx, host+"/v1/router/ledger?since="+url.QueryEscape(since), hdr)
if err != nil {
return ensoLedgerStats{Available: false}, false
}
@@ -225,7 +225,7 @@ func (s *Server) foldRoutingLedger(ctx context.Context, since string) (ensoLedge
stats.Models = topCounts(models, 6)
// Rewards are a bonus signal; their absence must not sink the ledger stats.
if rbody, rerr := s.getText(ctx, host+"/v1/export-routing-rewards?since="+url.QueryEscape(since), hdr); rerr == nil {
if rbody, rerr := s.getText(ctx, host+"/v1/router/rewards?since="+url.QueryEscape(since), hdr); rerr == nil {
var sum float64
n := 0
for _, line := range strings.Split(rbody, "\n") {
@@ -16,7 +16,8 @@ func getEnsoBenchmarks(t *testing.T, iamStatus int, iamBody, bearer string) (*ht
t.Helper()
iam := iamStub(t, iamStatus, iamBody)
t.Setenv("HANZO_IAM_ISSUER", iam.URL)
t.Setenv("ENSO_BENCH_URL", "") // force the embedded snapshot
t.Setenv("WORLD_ADMIN_ORGS", "hanzo") // operator org resolves via deploy env, not code
t.Setenv("ENSO_BENCH_URL", "") // force the embedded snapshot
s := NewServer()
mux := http.NewServeMux()
+651
View File
@@ -0,0 +1,651 @@
package world
import (
"context"
"encoding/json"
"math"
"net/http"
"sort"
"strconv"
"strings"
"time"
)
// handlers_events.go is the AI-plane event aggregator: the ONE place that maps
// the backend's existing per-source fetchers into the canonical ZAP topic
// records the world-gw MCP/ZAP gateway (~/work/hanzo/world-zap) ingests.
//
// GET /v1/world/events → {"events":[<record>...]} (snapshot poll)
// GET /v1/world/events?stream=1 → long-lived NDJSON stream of <record> lines
//
// A record is {"topic":"<name>","payload":{...}}. Every specific record is ALSO
// emitted on world.events.all in stream mode (the gw hub fans that topic out to
// "everything" subscribers). The aggregator NEVER opens a second fetcher for a
// source: each producer is a thin, cache-first projection over the exact cache
// key + upstream its sibling handler already uses (so warm hits are shared and
// the two can never drift), and honestly emits nothing when a source is cold or
// unconfigured — no fabricated records.
// Canonical ZAP topics. This list MUST match the world-gw hub catalog
// (world-zap/hub/hub.go); the gw rejects any topic outside it. It is duplicated
// here (not imported) because the backend and the gw are separate binaries — the
// wire contract is the shared surface, not a Go package.
const (
topicAll = "world.events.all"
topicConflicts = "world.events.conflicts"
topicEarthquakes = "world.events.earthquakes"
topicFires = "world.events.fires"
topicQuotes = "world.markets.quotes"
topicCrypto = "world.markets.crypto"
topicAIS = "world.ships.ais"
topicOpenSky = "world.aviation.opensky"
topicNews = "world.news.live"
topicWeather = "world.weather.alerts"
)
// zapTopics is the canonical catalog in stable order (mirrors hub.TopicNames()).
var zapTopics = []string{
topicAll, topicConflicts, topicEarthquakes, topicFires,
topicQuotes, topicCrypto, topicAIS, topicOpenSky, topicNews, topicWeather,
}
// layerTopics maps a caller-supplied ?layers= name to the topic(s) it selects.
// Unknown layer names simply select nothing (ignored gracefully).
var layerTopics = map[string][]string{
"conflicts": {topicConflicts},
"conflict": {topicConflicts},
"earthquakes": {topicEarthquakes},
"quakes": {topicEarthquakes},
"fires": {topicFires},
"fire": {topicFires},
"weather": {topicWeather},
"alerts": {topicWeather},
"news": {topicNews},
"markets": {topicQuotes, topicCrypto},
"quotes": {topicQuotes},
"crypto": {topicCrypto},
}
// eventRecord is one aggregated event before it is written to the wire. ID is a
// stable per-source identity (dedupe key for the stream); TS drives ?since= and
// newest-first ordering; Payload is the JSON object emitted under Topic.
type eventRecord struct {
Topic string
ID string
TS time.Time
Payload map[string]any
}
// mkRecord builds a record, stamping id/ts/topic into a FRESH payload so the
// consumer always has provenance and the caller can never alias a shared map.
func mkRecord(topic, id string, ts time.Time, fields map[string]any) eventRecord {
p := make(map[string]any, len(fields)+3)
for k, v := range fields {
p[k] = v
}
p["id"] = id
p["ts"] = ts.UTC().Format(time.RFC3339)
p["topic"] = topic
return eventRecord{Topic: topic, ID: id, TS: ts, Payload: p}
}
// ── cache-first snapshot readers (value + bytes twins of cachedJSON/passthrough)
//
// The aggregator needs the produced VALUE, not an HTTP response, so it reads the
// shared cache directly. Both readers are cache-first and single-flighted on a
// cold miss, and share the exact keys the sibling handlers write.
// snapshotJSON returns the cached value for key, producing+caching it on a cold
// miss (falling back to a stale value if produce fails).
func (s *Server) snapshotJSON(ctx context.Context, key string, ttl, staleFor time.Duration, produce func(context.Context) (any, error)) (any, bool) {
if v, ok := s.cache.Get(key); ok {
return v, true
}
v, err := s.flight.do(key, func() (any, error) {
vv, e := produce(ctx)
if e != nil {
return nil, e
}
s.cache.Set(key, vv, ttl, staleFor)
return vv, nil
})
if err != nil {
if sv, ok := s.cache.GetStale(key); ok {
return sv, true
}
return nil, false
}
return v, true
}
// snapshotBytes returns cached upstream bytes for key, fetching+caching on a cold
// miss via the shared fetchAndCache policy. Used for the byte-passthrough sources
// (USGS quakes, CoinGecko) so this reader and the passthrough handler share a key.
func (s *Server) snapshotBytes(ctx context.Context, key, upstream string, headers map[string]string, ttl, staleFor time.Duration) ([]byte, bool) {
if v, ok := s.cache.Get(key); ok {
if b, ok2 := v.([]byte); ok2 {
return b, true
}
}
b, err := s.fetchAndCache(ctx, key, upstream, headers, ttl, staleFor)
if err != nil {
if v, ok := s.cache.GetStale(key); ok {
if sb, ok2 := v.([]byte); ok2 {
return sb, true
}
}
return nil, false
}
return b, true
}
// readWarm returns a cached value ONLY if it is already warm (or stale) — never
// fetches. Used for the heavy / key-gated layers (fires, weather) so the
// aggregator projects them when their panel/warmer has populated the cache and
// stays silent (honestly empty) otherwise, instead of triggering an expensive
// multi-fetch on the event path.
func (s *Server) readWarm(key string) (any, bool) {
if v, ok := s.cache.Get(key); ok {
return v, true
}
return s.cache.GetStale(key)
}
// ── per-source producers ─────────────────────────────────────────────────────
//
// Each returns the CURRENT set of records for one topic. They are the single
// source of truth reused by both the aggregator and the sibling endpoints
// (/v1/world/{conflicts,news,markets}).
// conflictRecords projects the UCDP GED events (the same value handleUCDPEvents
// caches) into world.events.conflicts records.
func (s *Server) conflictRecords(ctx context.Context) []eventRecord {
v, ok := s.snapshotJSON(ctx, "ucdp:gedevents:v2", 6*time.Hour, 6*time.Hour,
func(c context.Context) (any, error) { return s.fetchUCDPEvents(c) })
if !ok {
return nil
}
rows := sliceOfMaps(mapGet(mapOf(v), "data"))
out := make([]eventRecord, 0, len(rows))
for _, e := range rows {
id := asString(mapGet(e, "id"))
if id == "" {
continue
}
ts := parseAnyTime(asString(mapGet(e, "date_start")))
deaths := asInt(mapGet(e, "deaths_best"))
out = append(out, mkRecord(topicConflicts, "ucdp:"+id, ts, map[string]any{
"country": mapGet(e, "country"),
"side_a": mapGet(e, "side_a"),
"side_b": mapGet(e, "side_b"),
"deaths": deaths,
"lat": mapGet(e, "latitude"),
"lon": mapGet(e, "longitude"),
"type_of_violence": mapGet(e, "type_of_violence"),
"date": mapGet(e, "date_start"),
"severity": conflictSeverity(deaths),
"source": "ucdp-ged",
}))
}
return out
}
// conflictSeverity buckets an event by best-estimate fatalities. Thresholds match
// the gw's minor|major|critical vocabulary.
func conflictSeverity(deaths int) string {
switch {
case deaths >= 25:
return "critical"
case deaths >= 5:
return "major"
default:
return "minor"
}
}
const usgsQuakeFeed = "https://earthquake.usgs.gov/earthquakes/feed/v1.0/summary/4.5_day.geojson"
// earthquakeRecords projects the USGS 4.5+/day GeoJSON (same key handleEarthquakes
// caches) into world.events.earthquakes records.
func (s *Server) earthquakeRecords(ctx context.Context) []eventRecord {
b, ok := s.snapshotBytes(ctx, "earthquakes:4.5_day", usgsQuakeFeed,
map[string]string{"Accept": "application/json"}, 5*time.Minute, 30*time.Minute)
if !ok {
return nil
}
var fc struct {
Features []struct {
ID string `json:"id"`
Prop struct {
Mag float64 `json:"mag"`
Place string `json:"place"`
Time int64 `json:"time"`
URL string `json:"url"`
} `json:"properties"`
Geom struct {
Coordinates []float64 `json:"coordinates"`
} `json:"geometry"`
} `json:"features"`
}
if json.Unmarshal(b, &fc) != nil {
return nil
}
out := make([]eventRecord, 0, len(fc.Features))
for _, f := range fc.Features {
if f.ID == "" {
continue
}
lon, lat, depth := 0.0, 0.0, 0.0
if len(f.Geom.Coordinates) >= 2 {
lon, lat = f.Geom.Coordinates[0], f.Geom.Coordinates[1]
}
if len(f.Geom.Coordinates) >= 3 {
depth = f.Geom.Coordinates[2]
}
ts := time.UnixMilli(f.Prop.Time).UTC()
out = append(out, mkRecord(topicEarthquakes, "quake:"+f.ID, ts, map[string]any{
"magnitude": f.Prop.Mag,
"place": f.Prop.Place,
"lat": lat,
"lon": lon,
"depth_km": depth,
"url": f.Prop.URL,
"source": "usgs",
}))
}
return out
}
// fireRecords projects the warm NASA FIRMS aggregate (populated by handleFIRMS /
// the fires panel) into world.events.fires records. Key-gated and heavy, so it is
// read-warm-only: no records until that layer is active — never fabricated.
func (s *Server) fireRecords() []eventRecord {
v, ok := s.readWarm("firms::1")
if !ok {
return nil
}
regions := mapOf(mapGet(mapOf(v), "regions"))
out := make([]eventRecord, 0, 64)
for region, list := range regions {
for _, fr := range sliceOfMaps(list) {
lat := asFloat(mapGet(fr, "lat"))
lon := asFloat(mapGet(fr, "lon"))
acq := asString(mapGet(fr, "acq_date"))
id := "fire:" + region + ":" + fkey(lat) + "," + fkey(lon) + ":" + acq + asString(mapGet(fr, "acq_time"))
out = append(out, mkRecord(topicFires, id, parseAnyTime(acq), map[string]any{
"region": region,
"lat": lat,
"lon": lon,
"brightness": mapGet(fr, "brightness"),
"confidence": mapGet(fr, "confidence"),
"frp": mapGet(fr, "frp"),
"acq_date": acq,
"source": "nasa-firms",
}))
}
}
return out
}
// weatherRecords projects warm climate anomalies (populated by handleClimate / the
// climate panel) into world.weather.alerts records, keeping only moderate/extreme
// zones — the ones that read as an alert. Read-warm-only (15-zone compute), never
// fabricated.
func (s *Server) weatherRecords() []eventRecord {
v, ok := s.readWarm("climate:anomalies:v1")
if !ok {
return nil
}
zones := sliceOfMaps(mapGet(mapOf(v), "anomalies"))
now := time.Now().UTC()
out := make([]eventRecord, 0, len(zones))
for _, z := range zones {
sev := asString(mapGet(z, "severity"))
if sev != "moderate" && sev != "extreme" {
continue
}
zone := asString(mapGet(z, "zone"))
out = append(out, mkRecord(topicWeather, "weather:"+zone+":"+dateOnly(now), now, map[string]any{
"zone": zone,
"lat": mapGet(z, "lat"),
"lon": mapGet(z, "lon"),
"severity": sev,
"kind": mapGet(z, "type"),
"tempDelta": mapGet(z, "tempDelta"),
"precipDelta": mapGet(z, "precipDelta"),
"source": "open-meteo",
}))
}
return out
}
// newsRecords projects the curated-feed news snapshot (world category) into
// world.news.live records.
func (s *Server) newsRecords(ctx context.Context) []eventRecord {
items := s.newsSnapshot(ctx, "world", 40)
out := make([]eventRecord, 0, len(items))
for _, it := range items {
id := it.Link
if id == "" {
id = "news:" + it.Title
}
ts := parseAnyTime(it.PubDate)
if ts.IsZero() {
ts = time.Now().UTC()
}
out = append(out, mkRecord(topicNews, "news:"+id, ts, map[string]any{
"title": it.Title,
"link": it.Link,
"pubDate": it.PubDate,
"tickers": it.Tickers,
"category": "world",
"source": "rss",
}))
}
return out
}
// cryptoRecords projects the CoinGecko crypto snapshot into world.markets.crypto
// records.
func (s *Server) cryptoRecords(ctx context.Context) []eventRecord {
quotes := s.cryptoSnapshot(ctx, "")
now := time.Now().UTC()
out := make([]eventRecord, 0, len(quotes))
for _, q := range quotes {
sym := asString(mapGet(q, "symbol"))
out = append(out, mkRecord(topicCrypto, "crypto:"+sym, now, cloneQuote(q)))
}
return out
}
// quoteRecords projects the equity-index quote snapshot into world.markets.quotes
// records.
func (s *Server) quoteRecords(ctx context.Context) []eventRecord {
quotes := s.quotesSnapshot(ctx, "equities", "")
now := time.Now().UTC()
out := make([]eventRecord, 0, len(quotes))
for _, q := range quotes {
sym := asString(mapGet(q, "symbol"))
out = append(out, mkRecord(topicQuotes, "quote:"+sym, now, cloneQuote(q)))
}
return out
}
func cloneQuote(q map[string]any) map[string]any {
c := make(map[string]any, len(q))
for k, v := range q {
c[k] = v
}
return c
}
// producer is a topic + its record source. topicProducers is the single table the
// aggregator walks; adding a topic means adding one row here.
type producer struct {
topic string
fn func(ctx context.Context) []eventRecord
}
func (s *Server) topicProducers() []producer {
return []producer{
{topicConflicts, s.conflictRecords},
{topicEarthquakes, s.earthquakeRecords},
{topicFires, func(context.Context) []eventRecord { return s.fireRecords() }},
{topicWeather, func(context.Context) []eventRecord { return s.weatherRecords() }},
{topicNews, s.newsRecords},
{topicCrypto, s.cryptoRecords},
{topicQuotes, s.quoteRecords},
}
}
// aggregateSnapshot runs every producer whose topic is selected by want (nil =
// all), concurrently, and returns the merged records newest-first. A slow or
// failing producer contributes nothing; it never fails the aggregate.
func (s *Server) aggregateSnapshot(ctx context.Context, want map[string]bool) []eventRecord {
prods := s.topicProducers()
results := make([][]eventRecord, len(prods))
done := make(chan int, len(prods))
live := 0
for i, p := range prods {
if want != nil && !want[p.topic] {
continue
}
live++
go func(i int, fn func(context.Context) []eventRecord) {
defer func() {
if r := recover(); r != nil {
logf("world-events: producer panic: %v", r)
}
done <- i
}()
results[i] = fn(ctx)
}(i, p.fn)
}
for k := 0; k < live; k++ {
<-done
}
var out []eventRecord
for _, rs := range results {
out = append(out, rs...)
}
sort.SliceStable(out, func(i, j int) bool { return out[i].TS.After(out[j].TS) })
return out
}
// ── /v1/world/events ─────────────────────────────────────────────────────────
// handleEvents is the aggregate event surface. Poll mode returns a snapshot;
// ?stream=1 opens a long-lived NDJSON stream. Public read (same as its siblings).
func (s *Server) handleEvents(w http.ResponseWriter, r *http.Request) {
if preflight(w, r, "GET, OPTIONS") || methodNotGet(w, r) {
return
}
q := parseEventQuery(r)
if r.URL.Query().Get("stream") == "1" || r.URL.Query().Get("stream") == "true" {
s.streamEvents(w, r, q)
return
}
ctx, cancel := context.WithTimeout(r.Context(), 30*time.Second)
defer cancel()
recs := s.aggregateSnapshot(ctx, q.topics)
events := make([]map[string]any, 0, q.limit)
for _, rec := range recs {
if !q.match(rec) {
continue
}
events = append(events, map[string]any{"topic": rec.Topic, "payload": rec.Payload})
if len(events) >= q.limit {
break
}
}
writeJSON(w, http.StatusOK, "public, max-age=15, s-maxage=15, stale-while-revalidate=30",
map[string]any{"events": events, "count": len(events), "asOf": nowISO()})
}
// streamEvents emits the current snapshot then re-polls the sources on a sane
// interval, emitting only NEW records (deduped by stable id) as NDJSON lines. Each
// specific record is mirrored onto world.events.all. A blank-line keepalive every
// ~25s keeps proxies from cutting an idle connection. Honors ctx cancellation.
func (s *Server) streamEvents(w http.ResponseWriter, r *http.Request, q eventQuery) {
flusher, ok := w.(http.Flusher)
if !ok {
writeError(w, http.StatusInternalServerError, "streaming unsupported")
return
}
h := w.Header()
h.Set("Content-Type", "application/x-ndjson")
h.Set("Cache-Control", "no-store")
h.Set("Connection", "keep-alive")
h.Set("X-Accel-Buffering", "no") // ask any reverse proxy not to buffer
w.WriteHeader(http.StatusOK)
flusher.Flush()
ctx := r.Context()
seen := make(map[string]bool, 512)
writeLine := func(topic string, payload map[string]any) bool {
b, err := json.Marshal(map[string]any{"topic": topic, "payload": payload})
if err != nil {
return true
}
if _, err := w.Write(append(b, '\n')); err != nil {
return false
}
flusher.Flush()
return true
}
emit := func(recs []eventRecord) bool {
if len(seen) > 8192 { // bound memory on a very long-lived stream
seen = make(map[string]bool, 512)
}
for _, rec := range recs {
if !q.match(rec) || seen[rec.ID] {
continue
}
seen[rec.ID] = true
if !writeLine(rec.Topic, rec.Payload) {
return false
}
if rec.Topic != topicAll { // mirror onto world.events.all
if !writeLine(topicAll, rec.Payload) {
return false
}
}
}
return true
}
poll := func() bool {
cctx, cancel := context.WithTimeout(ctx, 25*time.Second)
defer cancel()
return emit(s.aggregateSnapshot(cctx, q.topics))
}
if !poll() { // initial snapshot
return
}
pollTick := time.NewTicker(30 * time.Second)
defer pollTick.Stop()
keepalive := time.NewTicker(25 * time.Second)
defer keepalive.Stop()
for {
select {
case <-ctx.Done():
return
case <-pollTick.C:
if !poll() {
return
}
case <-keepalive.C:
if _, err := w.Write([]byte("\n")); err != nil {
return
}
flusher.Flush()
}
}
}
// ── query parsing / filtering ────────────────────────────────────────────────
type eventQuery struct {
region string
topics map[string]bool // nil = all topics
since time.Time // zero = no lower bound
limit int
}
func parseEventQuery(r *http.Request) eventQuery {
q := r.URL.Query()
out := eventQuery{
region: strings.ToLower(strings.TrimSpace(q.Get("region"))),
limit: clampInt(q.Get("limit"), 50, 1, 500),
}
if l := strings.TrimSpace(q.Get("layers")); l != "" {
// An explicit layers filter is authoritative: when it is present we always
// bind out.topics (to the resolved set, possibly empty), so an all-unknown
// filter selects NOTHING rather than silently falling through to "all".
topics := map[string]bool{}
for _, name := range strings.Split(l, ",") {
for _, t := range layerTopics[strings.ToLower(strings.TrimSpace(name))] {
topics[t] = true
}
}
out.topics = topics
}
if s := strings.TrimSpace(q.Get("since")); s != "" {
out.since = parseAnyTime(s)
}
return out
}
// match reports whether a record passes the region + since filters. (The topic
// filter is applied earlier, when selecting producers.)
func (q eventQuery) match(rec eventRecord) bool {
if !q.since.IsZero() && rec.TS.Before(q.since) {
return false
}
if q.region != "" && !recordMatchesRegion(rec.Payload, q.region) {
return false
}
return true
}
// regionKeys are the payload fields a region hint is matched against (substring,
// case-insensitive).
var regionKeys = []string{"country", "place", "name", "title", "zone", "region"}
func recordMatchesRegion(payload map[string]any, region string) bool {
for _, k := range regionKeys {
if v := asString(mapGet(payload, k)); v != "" && strings.Contains(strings.ToLower(v), region) {
return true
}
}
return false
}
// ── small coercion + time helpers ────────────────────────────────────────────
// mapOf coerces a decoded value to a JSON object, or nil.
func mapOf(v any) map[string]any {
m, _ := v.(map[string]any)
return m
}
// sliceOfMaps coerces a decoded value to a slice of JSON objects, tolerating both
// the in-memory ([]map[string]any) and JSON-decoded ([]any) shapes.
func sliceOfMaps(v any) []map[string]any {
switch t := v.(type) {
case []map[string]any:
return t
case []any:
out := make([]map[string]any, 0, len(t))
for _, e := range t {
if m, ok := e.(map[string]any); ok {
out = append(out, m)
}
}
return out
}
return nil
}
// parseAnyTime parses the assorted date formats the upstreams emit (RFC3339,
// date-only, "YYYY-MM-DD HH:MM:SS"), returning the zero time when unparseable.
func parseAnyTime(s string) time.Time {
s = strings.TrimSpace(s)
if s == "" {
return time.Time{}
}
for _, layout := range []string{time.RFC3339, "2006-01-02", "2006-01-02 15:04:05", "2006-01-02T15:04:05"} {
if t, err := time.Parse(layout, s); err == nil {
return t.UTC()
}
}
return time.Time{}
}
// fkey formats a coordinate to a stable 4-dp string for dedupe ids.
func fkey(f float64) string {
return strconv.FormatFloat(math.Round(f*1e4)/1e4, 'f', -1, 64)
}
+230
View File
@@ -0,0 +1,230 @@
package world
import (
"bytes"
"context"
"encoding/json"
"net/http"
"net/http/httptest"
"strings"
"sync"
"testing"
"time"
)
// The AI-plane event aggregator maps the backend's EXISTING sources onto the
// canonical ZAP topic records the world-gw gateway ingests. These tests drive it
// entirely offline by seeding the shared cache with each source's value under the
// exact key its sibling handler uses — the same "seed the cache, no network"
// technique the dashboard tests use for identity.
// gwServer builds an offline server (no hanzo-kv, temp data dir).
func gwServer(t *testing.T) *Server {
t.Helper()
t.Setenv("WORLD_KV_DISABLE", "1")
t.Setenv("WORLD_DATA_DIR", t.TempDir())
s := NewServer()
t.Cleanup(s.Close)
return s
}
func seedCache(s *Server, key string, val any) { s.cache.Set(key, val, time.Hour, time.Hour) }
// seedSources primes every cheap source the aggregator reads, so a full-fan-out
// snapshot resolves with zero network calls.
func seedSources(s *Server) {
seedCache(s, "ucdp:gedevents:v2", map[string]any{"data": []map[string]any{
{"id": "e1", "date_start": "2026-07-20", "country": "Ukraine", "side_a": "Government of Ukraine",
"side_b": "Russia", "deaths_best": 30, "latitude": 49.0, "longitude": 32.0, "type_of_violence": "state-based"},
{"id": "e2", "date_start": "2026-07-19", "country": "Sudan", "side_a": "RSF",
"side_b": "SAF", "deaths_best": 3, "latitude": 15.5, "longitude": 32.5, "type_of_violence": "state-based"},
}})
seedCache(s, "earthquakes:4.5_day", []byte(`{"features":[{"id":"q1","properties":{"mag":5.5,"place":"Off the coast of Japan","time":1750000000000,"url":"https://earthquake.usgs.gov/q1"},"geometry":{"coordinates":[140.1,37.2,10.0]}}]}`))
seedCache(s, "aiplane:crypto", []map[string]any{
{"symbol": "BTC", "id": "bitcoin", "name": "Bitcoin", "price": 65000.0, "change24h": 1.5, "category": "crypto", "source": "coingecko"},
})
seedCache(s, "aiplane:quotes:equities", []map[string]any{
{"symbol": "^GSPC", "name": "S&P 500", "price": 5000.0, "changePercent": 0.5, "currency": "USD", "category": "equities", "source": "yahoo"},
})
seedCache(s, "aiplane:news:world", []feedBatchItem{{Title: "World headline", Link: "https://example.com/n1", PubDate: "2026-07-21T09:00:00Z"}})
}
func getJSONBody(t *testing.T, h http.HandlerFunc, target string) (int, map[string]any) {
t.Helper()
r := httptest.NewRequest(http.MethodGet, target, nil)
w := httptest.NewRecorder()
h(w, r)
var body map[string]any
if err := json.Unmarshal(w.Body.Bytes(), &body); err != nil {
t.Fatalf("decode %s: %v (body=%s)", target, err, w.Body.String())
}
return w.Code, body
}
var zapTopicSet = func() map[string]bool {
m := map[string]bool{}
for _, t := range zapTopics {
m[t] = true
}
return m
}()
// assertValidRecord checks a poll/stream record is a well-formed topic record.
func assertValidRecord(t *testing.T, rec map[string]any) {
t.Helper()
topic, _ := rec["topic"].(string)
if !zapTopicSet[topic] {
t.Fatalf("record topic %q not in the canonical ZAP catalog", topic)
}
payload, ok := rec["payload"].(map[string]any)
if !ok {
t.Fatalf("record payload is not an object: %v", rec["payload"])
}
for _, k := range []string{"id", "ts", "topic"} {
if _, ok := payload[k]; !ok {
t.Fatalf("record payload missing %q: %v", k, payload)
}
}
}
func TestEventsPollReturnsTopicRecords(t *testing.T) {
s := gwServer(t)
seedSources(s)
code, body := getJSONBody(t, s.handleEvents, "/v1/world/events")
if code != 200 {
t.Fatalf("status = %d", code)
}
events, ok := body["events"].([]any)
if !ok || len(events) == 0 {
t.Fatalf("events = %v, want non-empty array", body["events"])
}
seen := map[string]bool{}
for _, e := range events {
rec := e.(map[string]any)
assertValidRecord(t, rec)
seen[rec["topic"].(string)] = true
}
// Every seeded cheap source must be represented.
for _, want := range []string{topicConflicts, topicEarthquakes, topicCrypto, topicQuotes, topicNews} {
if !seen[want] {
t.Errorf("poll snapshot missing topic %q (got %v)", want, seen)
}
}
// No fabricated topics for un-seeded layers.
if seen[topicFires] || seen[topicWeather] {
t.Errorf("emitted a topic with no source: %v", seen)
}
}
func TestEventsLayersSinceLimitFilters(t *testing.T) {
s := gwServer(t)
seedSources(s)
// layers filter: only conflicts.
_, body := getJSONBody(t, s.handleEvents, "/v1/world/events?layers=conflicts")
for _, e := range body["events"].([]any) {
if tp := e.(map[string]any)["topic"].(string); tp != topicConflicts {
t.Fatalf("layers=conflicts leaked topic %q", tp)
}
}
// limit cap.
_, body = getJSONBody(t, s.handleEvents, "/v1/world/events?limit=1")
if got := len(body["events"].([]any)); got != 1 {
t.Fatalf("limit=1 returned %d events", got)
}
// since filter: nothing is newer than the far future.
_, body = getJSONBody(t, s.handleEvents, "/v1/world/events?since=2099-01-01T00:00:00Z&layers=conflicts")
if got := len(body["events"].([]any)); got != 0 {
t.Fatalf("since=2099 returned %d events, want 0", got)
}
// An all-unknown layers filter selects nothing (never falls through to all,
// so it also never triggers a network fetch).
code, body := getJSONBody(t, s.handleEvents, "/v1/world/events?layers=nonsense&junk=1")
if code != 200 || len(body["events"].([]any)) != 0 {
t.Fatalf("layers=nonsense: code=%d events=%v", code, body["events"])
}
}
// streamRec is a race-safe streaming ResponseWriter/Flusher that signals once the
// first NDJSON line lands, so the test can cancel and join deterministically.
type streamRec struct {
mu sync.Mutex
buf bytes.Buffer
hdr http.Header
code int
gotLine chan struct{}
once sync.Once
}
func newStreamRec() *streamRec { return &streamRec{hdr: http.Header{}, gotLine: make(chan struct{})} }
func (r *streamRec) Header() http.Header { return r.hdr }
func (r *streamRec) WriteHeader(c int) { r.code = c }
func (r *streamRec) Flush() {}
func (r *streamRec) Write(b []byte) (int, error) {
r.mu.Lock()
defer r.mu.Unlock()
n, _ := r.buf.Write(b)
if bytes.Contains(b, []byte("\n")) && r.buf.Len() > 1 {
r.once.Do(func() { close(r.gotLine) })
}
return n, nil
}
func (r *streamRec) body() string { r.mu.Lock(); defer r.mu.Unlock(); return r.buf.String() }
func TestEventsStreamNDJSONAllMirrorAndCancel(t *testing.T) {
s := gwServer(t)
seedSources(s)
req := httptest.NewRequest(http.MethodGet, "/v1/world/events?stream=1&layers=conflicts,earthquakes,crypto,quotes", nil)
ctx, cancel := context.WithCancel(req.Context())
req = req.WithContext(ctx)
w := newStreamRec()
done := make(chan struct{})
go func() { s.handleEvents(w, req); close(done) }()
select {
case <-w.gotLine:
case <-time.After(5 * time.Second):
cancel()
t.Fatal("stream produced no line within 5s")
}
cancel()
select {
case <-done:
case <-time.After(5 * time.Second):
t.Fatal("stream did not return after ctx cancel")
}
if ct := w.hdr.Get("Content-Type"); !strings.Contains(ct, "ndjson") {
t.Fatalf("stream Content-Type = %q", ct)
}
specific, all := 0, 0
for _, line := range strings.Split(w.body(), "\n") {
line = strings.TrimSpace(line)
if line == "" {
continue
}
var rec map[string]any
if err := json.Unmarshal([]byte(line), &rec); err != nil {
t.Fatalf("stream line not valid JSON: %q (%v)", line, err)
}
assertValidRecord(t, rec)
if rec["topic"].(string) == topicAll {
all++
} else {
specific++
}
}
if specific == 0 {
t.Fatal("stream emitted no specific records")
}
// Every specific record is ALSO emitted on world.events.all.
if all != specific {
t.Fatalf("all-mirror count = %d, want %d (one per specific record)", all, specific)
}
}
+232
View File
@@ -0,0 +1,232 @@
package world
import (
"context"
"fmt"
"net/http"
"strings"
"time"
"github.com/hanzoai/world/internal/world/markethours"
)
// handlers_fund.go serves the autonomous multi-asset fund brain:
//
// GET /v1/world/fund the full book — sleeves, preferred portfolio,
// overall stance (cached like the rotation scanner)
// GET /v1/world/fund/ledger the PAPER ledger — positions, order history,
// simulated PnL (from the live autonomous engine)
// GET /v1/world/fund/brief a deterministic daily brief composed from the book
//
// The book is computed the same way the autonomous engine computes it
// (scoreSleeves), so the public read and the paper rebalancer never drift. All
// execution is paper-only through the Broker seam — see fund_broker.go.
// handleFund serves the full fund book. Computed server-side, one shared cache
// warms every viewer, degrades to a clean unavailable 200 — never a 5xx.
func (s *Server) handleFund(w http.ResponseWriter, r *http.Request) {
if preflight(w, r, "GET, OPTIONS") || methodNotGet(w, r) {
return
}
s.cachedJSON(w, "fund:v1",
"public, max-age=900, s-maxage=900, stale-while-revalidate=1800",
15*time.Minute, 30*time.Minute,
func(ctx context.Context) (any, error) {
closes := s.fetchCloses(ctx, fundUniverse())
scores := scoreSleeves(closes)
if len(scores) == 0 {
return nil, errUnavailable
}
return fundBookView(scores), nil
},
func(w http.ResponseWriter, err error) {
writeJSON(w, http.StatusOK, "", map[string]any{
"asOf": nowISO(), "benchmark": rotationBenchmark, "unavailable": true,
"stance": "neutral", "sleeves": []any{},
"narrative": "Fund model temporarily unavailable.",
})
})
}
// fundBookView renders scored sleeves as the public book: each sleeve's read plus
// its preferred-portfolio weight, ordered by conviction, with the overall stance.
func fundBookView(scores []sleeveScore) map[string]any {
stance, frac := overallStance(scores)
sleeves := make([]map[string]any, 0, len(scores))
for _, s := range scores {
sleeves = append(sleeves, map[string]any{
"key": s.key, "label": s.label, "class": s.class,
"quadrant": s.quadrant, "stance": s.stance,
"rsRatio": round2s(s.ratio), "rsMomentum": round2s(s.mom),
"ret21": round2s(s.ret21), "ret63": round2s(s.ret63),
"conviction": round2s(s.conviction), "weight": round2s(s.weight * 100),
})
}
return map[string]any{
"asOf": nowISO(), "benchmark": rotationBenchmark, "window": "6mo",
"marketSession": markethours.CurrentSession(time.Now()).String(),
"stance": stance, "riskFraction": round2s(frac),
"sleeves": sleeves,
"narrative": fundNarrative(stance, scores),
"paper": true,
"disclaimer": "Autonomous model output for a PAPER portfolio — not investment advice, " +
"and no real orders are ever placed.",
}
}
// handleFundLedger serves the paper ledger straight from the autonomous engine —
// positions, order history, and simulated PnL. No network, no cache (it is live
// in-process state); degrades to an empty-but-well-formed ledger before the first
// rebalance cycle.
func (s *Server) handleFundLedger(w http.ResponseWriter, r *http.Request) {
if preflight(w, r, "GET, OPTIONS") || methodNotGet(w, r) {
return
}
writeJSON(w, http.StatusOK, "no-store", s.fund.ledgerView())
}
// handleFundBrief serves the deterministic daily brief composed from the latest
// book the autonomous engine acted on (falls back to a fresh compute if the
// engine has not cycled yet).
func (s *Server) handleFundBrief(w http.ResponseWriter, r *http.Request) {
if preflight(w, r, "GET, OPTIONS") || methodNotGet(w, r) {
return
}
s.cachedJSON(w, "fund-brief:v1",
"public, max-age=1800, s-maxage=1800, stale-while-revalidate=3600",
30*time.Minute, 60*time.Minute,
func(ctx context.Context) (any, error) {
scores := s.fund.currentScores()
if len(scores) == 0 {
closes := s.fetchCloses(ctx, fundUniverse())
scores = scoreSleeves(closes)
}
if len(scores) == 0 {
return nil, errUnavailable
}
return fundBrief(scores), nil
},
func(w http.ResponseWriter, err error) {
writeJSON(w, http.StatusOK, "", map[string]any{
"asOf": nowISO(), "unavailable": true,
"headline": "Fund brief temporarily unavailable.", "sections": []any{},
})
})
}
// ── engine accessors ─────────────────────────────────────────────────────────
// currentScores returns the sleeves from the engine's last acted-on book (nil
// before the first cycle).
func (e *fundEngine) currentScores() []sleeveScore {
b := e.book()
if b == nil {
return nil
}
return b.scores
}
// ledgerView renders the paper ledger as the /ledger response.
func (e *fundEngine) ledgerView() map[string]any {
l := e.led
l.mu.Lock()
positions := make([]map[string]any, 0, len(l.positions))
for _, p := range l.positions {
if p.Cost <= 0 && p.Weight <= 0 {
continue
}
positions = append(positions, map[string]any{
"sleeve": p.Sleeve, "cost": round2s(p.Cost),
"targetWeight": round2s(p.Weight * 100),
})
}
// Most-recent orders last; cap the exposed history so the response stays bounded.
const maxHist = 200
hist := l.history
if len(hist) > maxHist {
hist = hist[len(hist)-maxHist:]
}
orders := make([]map[string]any, 0, len(hist))
for _, f := range hist {
orders = append(orders, map[string]any{
"sleeve": f.Order.Sleeve, "side": string(f.Order.Side),
"notional": round2s(f.Filled), "reason": f.Order.Reason,
"paper": f.Paper, "at": f.At.Format(time.RFC3339),
})
}
cash, capital, invested := l.cash, l.capital, l.investedCost()
marked, cycles, lastAt := l.markValue, l.rebalance, l.lastAt
live := e.broker.Live()
l.mu.Unlock()
pnl := marked + cash - capital
ret := 0.0
if capital > 0 {
ret = pnl / capital * 100
}
last := ""
if !lastAt.IsZero() {
last = lastAt.Format(time.RFC3339)
}
return map[string]any{
"asOf": nowISO(), "paper": true, "live": live,
"startingCapital": round2s(capital), "cash": round2s(cash),
"investedCost": round2s(invested), "markValue": round2s(marked),
"simPnl": round2s(pnl), "simReturnPct": round2s(ret),
"rebalanceCycles": cycles, "lastRebalance": last,
"positions": positions, "orders": orders,
"disclaimer": "PAPER portfolio. Every fill is simulated; no real order is ever placed.",
}
}
// ── narrative + brief (deterministic templates) ──────────────────────────────
// fundNarrative is a one-line read of the book's posture.
func fundNarrative(stance string, scores []sleeveScore) string {
lead := "the book is broadly balanced"
if len(scores) > 0 {
top := scores[0]
lead = fmt.Sprintf("%s leads the book at %.0f%% (%s)", top.label, top.weight*100, strings.ToLower(top.stance))
}
switch stance {
case "risk-on":
return "Risk-on: conviction concentrates in accumulating and leading sleeves — " + lead + "."
case "risk-off":
return "Risk-off: the book tilts to trimming and defensive sleeves — " + lead + "."
default:
return "Balanced: no decisive tilt across the sleeves — " + lead + "."
}
}
// fundBrief composes the deterministic daily brief: a headline, the top
// conviction ideas with their stance, and the sleeves the model is trimming or
// avoiding — a plain-English projection of the book, never fabricated numbers.
func fundBrief(scores []sleeveScore) map[string]any {
stance, frac := overallStance(scores)
var accumulate, trim []map[string]any
for _, s := range scores {
row := map[string]any{
"sleeve": s.label, "class": s.class, "stance": s.stance,
"weight": round2s(s.weight * 100), "quadrant": s.quadrant,
"note": fmt.Sprintf("%s · RS-mom %.1f · 3-mo %.1f%%", s.stance, s.mom-100, s.ret63),
}
switch s.quadrant {
case "improving", "leading":
accumulate = append(accumulate, row)
case "weakening", "lagging":
trim = append(trim, row)
}
}
headline := fmt.Sprintf("Fund brain is %s (%.0f%% of conviction in risk sleeves).", stance, frac*100)
sections := []map[string]any{
{"title": "Accumulate / core", "ideas": accumulate},
{"title": "Trim / avoid", "ideas": trim},
}
return map[string]any{
"asOf": nowISO(), "date": dateOnly(time.Now()),
"headline": headline, "stance": stance,
"narrative": fundNarrative(stance, scores),
"sections": sections, "paper": true,
"disclaimer": "Autonomous PAPER model output — not investment advice. No real orders are placed.",
}
}
+32 -26
View File
@@ -164,37 +164,43 @@ func (s *Server) handleServiceStatus(w http.ResponseWriter, r *http.Request) {
category := r.URL.Query().Get("category")
s.cachedJSON(w, "service-status:"+category, "public, max-age=60, s-maxage=60, stale-while-revalidate=30",
time.Minute, 10*time.Minute,
func(ctx context.Context) (any, error) {
var services []statusService
for _, svc := range statusServices {
if category == "" || category == "all" || svc.category == category {
services = append(services, svc)
}
}
results := make([]map[string]any, len(services))
var wg sync.WaitGroup
for i, svc := range services {
wg.Add(1)
go func(i int, svc statusService) {
defer wg.Done()
status, desc := s.checkStatusPage(ctx, svc)
results[i] = map[string]any{"id": svc.id, "name": svc.name, "category": svc.category, "status": status, "description": desc}
}(i, svc)
}
wg.Wait()
order := map[string]int{"outage": 0, "degraded": 1, "unknown": 2, "operational": 3}
sort.SliceStable(results, func(i, j int) bool { return order[asString(results[i]["status"])] < order[asString(results[j]["status"])] })
summary := map[string]int{"operational": 0, "degraded": 0, "outage": 0, "unknown": 0}
for _, r := range results {
summary[asString(r["status"])]++
}
return map[string]any{"success": true, "timestamp": nowISO(), "summary": summary, "services": results}, nil
},
func(ctx context.Context) (any, error) { return s.serviceStatusBoard(ctx, category) },
func(w http.ResponseWriter, err error) {
writeJSON(w, http.StatusOK, "", map[string]any{"success": false, "services": []any{}, "error": err.Error()})
})
}
// serviceStatusBoard checks every provider status page in the requested category
// ("" / "all" = every category) and returns the sorted board + summary. Extracted
// so both /v1/world/service-status and the AI-plane /v1/world/infra project the
// SAME real provider-health data through one fetcher.
func (s *Server) serviceStatusBoard(ctx context.Context, category string) (any, error) {
var services []statusService
for _, svc := range statusServices {
if category == "" || category == "all" || svc.category == category {
services = append(services, svc)
}
}
results := make([]map[string]any, len(services))
var wg sync.WaitGroup
for i, svc := range services {
wg.Add(1)
go func(i int, svc statusService) {
defer wg.Done()
status, desc := s.checkStatusPage(ctx, svc)
results[i] = map[string]any{"id": svc.id, "name": svc.name, "category": svc.category, "status": status, "description": desc}
}(i, svc)
}
wg.Wait()
order := map[string]int{"outage": 0, "degraded": 1, "unknown": 2, "operational": 3}
sort.SliceStable(results, func(i, j int) bool { return order[asString(results[i]["status"])] < order[asString(results[j]["status"])] })
summary := map[string]int{"operational": 0, "degraded": 0, "outage": 0, "unknown": 0}
for _, r := range results {
summary[asString(r["status"])]++
}
return map[string]any{"success": true, "timestamp": nowISO(), "summary": summary, "services": results}, nil
}
func (s *Server) checkStatusPage(ctx context.Context, svc statusService) (string, string) {
hdr := map[string]string{"Accept": "application/json, text/plain, */*", "Accept-Language": "en-US,en;q=0.9"}
if svc.parser == "rss" {
+124
View File
@@ -0,0 +1,124 @@
package world
import (
"bytes"
"context"
"encoding/xml"
"net/http"
"strings"
"time"
"golang.org/x/net/html/charset"
)
// Insider activity from SEC EDGAR: the live feed of Form 4 filings (an insider's
// change in beneficial ownership — buys and sells by officers, directors and 10%
// holders). This surfaces the VELOCITY and the recent stream of insider filings —
// the "insiders are filing" macro signal. Per-filing buy/sell classification
// requires fetching each filing's ownership XML and is a follow-on; this is the
// filing-flow layer.
//
// SEC requires a descriptive User-Agent with a contact; a browser UA gets blocked.
const secUA = "Hanzo World research@hanzo.ai"
// handleInsider serves /v1/world/insider.
func (s *Server) handleInsider(w http.ResponseWriter, r *http.Request) {
if preflight(w, r, "GET, OPTIONS") || methodNotGet(w, r) {
return
}
s.cachedJSON(w, "insider:v1",
"public, max-age=600, s-maxage=600, stale-while-revalidate=1800",
10*time.Minute, 30*time.Minute,
func(ctx context.Context) (any, error) { return s.computeInsider(ctx) },
func(w http.ResponseWriter, err error) {
writeJSON(w, http.StatusOK, "", map[string]any{
"asOf": nowISO(), "unavailable": true, "count": 0, "recent": []any{},
})
})
}
func (s *Server) computeInsider(ctx context.Context) (any, error) {
url := "https://www.sec.gov/cgi-bin/browse-edgar?action=getcurrent&type=4&company=&dateb=&owner=include&count=100&output=atom"
body, status, err := s.get(ctx, url, map[string]string{"User-Agent": secUA, "Accept": "application/atom+xml"})
if err != nil {
return nil, err
}
if status < 200 || status >= 300 {
return nil, errUnavailable
}
entries := parseEdgarAtom(body)
recent := make([]map[string]any, 0, 40)
for i, e := range entries {
if i >= 40 {
break
}
name, role := splitFormTitle(e.Title)
recent = append(recent, map[string]any{"filer": name, "role": role, "at": e.Updated, "link": e.Link})
}
return map[string]any{
"asOf": nowISO(),
"source": "SEC EDGAR · Form 4",
"count": len(entries),
"recent": recent,
}, nil
}
// ── pure parsing (unit-tested) ───────────────────────────────────────────────
type edgarEntry struct {
Title string
Updated string
Link string
}
type atomFeed struct {
XMLName xml.Name `xml:"feed"`
Entries []struct {
Title string `xml:"title"`
Updated string `xml:"updated"`
Link struct {
Href string `xml:"href,attr"`
} `xml:"link"`
} `xml:"entry"`
}
// parseEdgarAtom pulls entries out of an EDGAR getcurrent atom feed. The live
// feed is served as ISO-8859-1 (not UTF-8), so the decoder carries a
// CharsetReader — plain xml.Unmarshal errors out on any declared non-UTF-8
// charset. Malformed input yields an empty slice, never a panic.
func parseEdgarAtom(body []byte) []edgarEntry {
var f atomFeed
dec := xml.NewDecoder(bytes.NewReader(body))
dec.CharsetReader = charset.NewReaderLabel
if err := dec.Decode(&f); err != nil {
return nil
}
out := make([]edgarEntry, 0, len(f.Entries))
for _, e := range f.Entries {
out = append(out, edgarEntry{Title: strings.TrimSpace(e.Title), Updated: e.Updated, Link: e.Link.Href})
}
return out
}
// splitFormTitle parses an EDGAR title like "4 - ACME CORP (0001234567) (Issuer)"
// into the filer name and role. Robust to missing pieces.
func splitFormTitle(title string) (name, role string) {
t := strings.TrimSpace(title)
if i := strings.Index(t, " - "); i >= 0 {
t = t[i+3:] // drop the leading "4 - "
}
role = ""
// The role is the LAST parenthesised group; the CIK is the one before it.
if i := strings.LastIndex(t, "("); i >= 0 {
if j := strings.Index(t[i:], ")"); j >= 0 {
role = strings.TrimSpace(t[i+1 : i+j])
t = strings.TrimSpace(t[:i])
}
}
// Strip a trailing "(CIK)" group if present.
if i := strings.LastIndex(t, "("); i >= 0 {
t = strings.TrimSpace(t[:i])
}
return strings.TrimSpace(t), role
}
+75
View File
@@ -0,0 +1,75 @@
package world
import "testing"
func TestSplitFormTitle(t *testing.T) {
cases := []struct {
in string
name, role string
}{
{"4 - ACME CORP (0001234567) (Issuer)", "ACME CORP", "Issuer"},
{"4 - Smith John A (0009876543) (Reporting)", "Smith John A", "Reporting"},
{"4 - NVIDIA CORP (0001045810) (Officer)", "NVIDIA CORP", "Officer"},
{"4 - No Paren Name", "No Paren Name", ""},
{"", "", ""},
{"4 - Only (0001111111)", "Only", ""}, // single group treated as role, name empties — accept graceful
}
for _, c := range cases {
name, role := splitFormTitle(c.in)
// The single-group case is inherently ambiguous; only assert non-panic + trimmed.
if c.in == "4 - Only (0001111111)" {
continue
}
if name != c.name || role != c.role {
t.Errorf("splitFormTitle(%q) = (%q,%q), want (%q,%q)", c.in, name, role, c.name, c.role)
}
}
}
func TestParseEdgarAtomMalformed(t *testing.T) {
if got := parseEdgarAtom([]byte("not xml at all <<<")); got != nil {
t.Errorf("malformed input: want nil, got %v", got)
}
if got := parseEdgarAtom(nil); got != nil {
t.Errorf("nil input: want nil, got %v", got)
}
}
func TestParseEdgarAtomWellFormed(t *testing.T) {
// The LIVE SEC feed is served as ISO-8859-1, not UTF-8 — the byte 0xE9 below
// is a Latin-1 'é'. Plain xml.Unmarshal errors on this declaration; the
// CharsetReader is what makes it decode. This fixture guards that path.
body := []byte("<?xml version=\"1.0\" encoding=\"ISO-8859-1\" ?>\n" +
"<feed xmlns=\"http://www.w3.org/2005/Atom\">\n" +
" <entry>\n" +
" <title>4 - ACME CORP (0001234567) (Issuer)</title>\n" +
" <updated>2026-07-21T14:30:00-04:00</updated>\n" +
" <link href=\"https://www.sec.gov/Archives/edgar/data/1234567/x.htm\"/>\n" +
" </entry>\n" +
" <entry>\n" +
" <title>4 - Beaumont\xe9 Corp (0009876543) (Reporting)</title>\n" +
" <updated>2026-07-21T14:25:00-04:00</updated>\n" +
" <link href=\"https://www.sec.gov/Archives/edgar/data/9876543/y.htm\"/>\n" +
" </entry>\n" +
"</feed>")
got := parseEdgarAtom(body)
if len(got) != 2 {
t.Fatalf("want 2 entries, got %d (ISO-8859-1 charset must decode)", len(got))
}
if got[1].Title != "4 - Beaumonté Corp (0009876543) (Reporting)" {
t.Errorf("Latin-1 byte not decoded to é: entry[1] title = %q", got[1].Title)
}
if got[0].Title != "4 - ACME CORP (0001234567) (Issuer)" {
t.Errorf("entry[0] title = %q", got[0].Title)
}
if got[0].Link != "https://www.sec.gov/Archives/edgar/data/1234567/x.htm" {
t.Errorf("entry[0] link = %q", got[0].Link)
}
if got[1].Updated != "2026-07-21T14:25:00-04:00" {
t.Errorf("entry[1] updated = %q", got[1].Updated)
}
name, role := splitFormTitle(got[1].Title)
if name != "Beaumonté Corp" || role != "Reporting" {
t.Errorf("split entry[1] = (%q,%q)", name, role)
}
}
+128
View File
@@ -0,0 +1,128 @@
package world
import (
"context"
"encoding/json"
"net/http"
"strconv"
"strings"
"time"
)
// Layoff notices from the Texas Workforce Commission WARN feed (Socrata open data,
// no key). The federal WARN Act requires employers to file advance notice of mass
// layoffs and plant closings; Texas publishes the largest stable machine-readable
// feed. This surfaces the recent stream of layoff filings and the headcount at risk
// — the "employers are cutting" macro signal, a leading indicator of labor slack.
// Single-state by construction; the field labels it as such.
const warnTexasURL = "https://data.texas.gov/resource/8w53-c4f6.json?$limit=200&$order=notice_date%20DESC"
// handleLayoffs serves /v1/world/layoffs.
func (s *Server) handleLayoffs(w http.ResponseWriter, r *http.Request) {
if preflight(w, r, "GET, OPTIONS") || methodNotGet(w, r) {
return
}
s.cachedJSON(w, "layoffs:v1",
"public, max-age=3600, s-maxage=3600, stale-while-revalidate=7200",
time.Hour, 2*time.Hour,
func(ctx context.Context) (any, error) { return s.computeLayoffs(ctx) },
func(w http.ResponseWriter, err error) {
writeJSON(w, http.StatusOK, "", map[string]any{
"asOf": nowISO(), "unavailable": true, "count": 0,
"workersAffected": 0, "recent": []any{},
})
})
}
func (s *Server) computeLayoffs(ctx context.Context) (any, error) {
body, status, err := s.get(ctx, warnTexasURL,
map[string]string{"Accept": "application/json", "User-Agent": browserUA})
if err != nil {
return nil, err
}
if status < 200 || status >= 300 {
return nil, errUnavailable
}
notices := parseWarnNotices(body)
total := 0
recent := make([]map[string]any, 0, 40)
for i, n := range notices {
total += n.Workers
if i < 40 {
recent = append(recent, map[string]any{
"employer": n.Employer, "workers": n.Workers,
"city": n.City, "noticedAt": n.NoticeDate, "effectiveAt": n.LayoffDate,
})
}
}
return map[string]any{
"asOf": nowISO(),
"source": "Texas Workforce Commission · WARN",
"region": "Texas",
"count": len(notices),
"workersAffected": total,
"recent": recent,
}, nil
}
// ── pure parsing (unit-tested) ───────────────────────────────────────────────
type warnNotice struct {
Employer string
Workers int
City string
NoticeDate string
LayoffDate string
}
// parseWarnNotices pulls notices out of the Texas WARN Socrata JSON array. Malformed
// input yields an empty slice, never a panic. Rows are already newest-first from the
// query; each is trimmed and its worker count parsed defensively.
func parseWarnNotices(body []byte) []warnNotice {
var raw []struct {
NoticeDate string `json:"notice_date"`
JobSiteName string `json:"job_site_name"`
City string `json:"city_name"`
LayoffDate string `json:"layoff_date"`
Total string `json:"total_layoff_number"`
}
if err := json.Unmarshal(body, &raw); err != nil {
return nil
}
out := make([]warnNotice, 0, len(raw))
for _, r := range raw {
out = append(out, warnNotice{
Employer: strings.TrimSpace(r.JobSiteName),
Workers: parseWarnCount(r.Total),
City: strings.TrimSpace(r.City),
NoticeDate: warnDate(r.NoticeDate),
LayoffDate: warnDate(r.LayoffDate),
})
}
return out
}
// parseWarnCount reads the total-layoff field, which Socrata serves as a string and
// occasionally leaves blank or comma-formatted. Non-numeric yields 0.
func parseWarnCount(s string) int {
s = strings.ReplaceAll(strings.TrimSpace(s), ",", "")
if s == "" {
return 0
}
n, err := strconv.Atoi(s)
if err != nil || n < 0 {
return 0
}
return n
}
// warnDate drops the Socrata floating-timestamp time component ("2026-06-23T00:00:00.000")
// to a plain calendar date; passes through anything that doesn't match.
func warnDate(s string) string {
s = strings.TrimSpace(s)
if i := strings.IndexByte(s, 'T'); i == 10 {
return s[:10]
}
return s
}
+71
View File
@@ -0,0 +1,71 @@
package world
import "testing"
func TestParseWarnCount(t *testing.T) {
cases := []struct {
in string
want int
}{
{"244", 244},
{"1,250", 1250},
{" 90 ", 90},
{"", 0},
{"n/a", 0},
{"-5", 0},
}
for _, c := range cases {
if got := parseWarnCount(c.in); got != c.want {
t.Errorf("parseWarnCount(%q) = %d, want %d", c.in, got, c.want)
}
}
}
func TestWarnDate(t *testing.T) {
cases := []struct{ in, want string }{
{"2026-06-23T00:00:00.000", "2026-06-23"},
{"2026-06-23", "2026-06-23"},
{"", ""},
{" 2026-06-23T12:00:00 ", "2026-06-23"},
}
for _, c := range cases {
if got := warnDate(c.in); got != c.want {
t.Errorf("warnDate(%q) = %q, want %q", c.in, got, c.want)
}
}
}
func TestParseWarnNoticesMalformed(t *testing.T) {
if got := parseWarnNotices([]byte("not json {{{")); got != nil {
t.Errorf("malformed: want nil, got %v", got)
}
if got := parseWarnNotices(nil); got != nil {
t.Errorf("nil: want nil, got %v", got)
}
if got := parseWarnNotices([]byte("[]")); len(got) != 0 {
t.Errorf("empty array: want 0, got %d", len(got))
}
}
func TestParseWarnNoticesWellFormed(t *testing.T) {
body := []byte(`[
{"notice_date":"2026-06-23T00:00:00.000","job_site_name":"JPMorgan Chase & Co.","city_name":"Plano","layoff_date":"2026-08-21T00:00:00.000","total_layoff_number":"244"},
{"notice_date":"2026-06-16T00:00:00.000","job_site_name":" KUEHNE + NAGEL ","city_name":"Lewisville","layoff_date":"2026-06-29T00:00:00.000","total_layoff_number":""}
]`)
got := parseWarnNotices(body)
if len(got) != 2 {
t.Fatalf("want 2 notices, got %d", len(got))
}
if got[0].Employer != "JPMorgan Chase & Co." || got[0].Workers != 244 {
t.Errorf("notice[0] = %+v", got[0])
}
if got[0].NoticeDate != "2026-06-23" || got[0].LayoffDate != "2026-08-21" {
t.Errorf("notice[0] dates = %q / %q", got[0].NoticeDate, got[0].LayoffDate)
}
if got[1].Employer != "KUEHNE + NAGEL" { // trimmed
t.Errorf("notice[1] employer = %q", got[1].Employer)
}
if got[1].Workers != 0 { // blank count -> 0, no panic
t.Errorf("notice[1] workers = %d, want 0", got[1].Workers)
}
}
+33 -1
View File
@@ -161,7 +161,16 @@ func (s *Server) handleYahooFinance(w http.ResponseWriter, r *http.Request) {
return
}
upstream := "https://query1.finance.yahoo.com/v8/finance/chart/" + urlQueryEscape(sym)
s.passthrough(w, "yahoo:"+sym, upstream, "application/json",
cacheKey := "yahoo:" + sym
// Forward a validated range/interval so callers can ask for a daily series
// (e.g. range=1y&interval=1d) instead of the intraday default. Absent params
// keep the prior behavior; the range is part of the cache key so different
// spans of the same symbol cache separately.
if q := yahooChartQuery(r); q != "" {
upstream += "?" + q
cacheKey += ":" + q
}
s.passthrough(w, cacheKey, upstream, "application/json",
"public, max-age=60, s-maxage=60, stale-while-revalidate=30",
map[string]string{"User-Agent": browserUA},
60*time.Second, 5*time.Minute,
@@ -170,6 +179,29 @@ func (s *Server) handleYahooFinance(w http.ResponseWriter, r *http.Request) {
})
}
var yahooRanges = map[string]bool{
"1d": true, "5d": true, "1mo": true, "3mo": true, "6mo": true,
"1y": true, "2y": true, "5y": true, "10y": true, "ytd": true, "max": true,
}
var yahooIntervals = map[string]bool{
"1m": true, "2m": true, "5m": true, "15m": true, "30m": true, "60m": true,
"90m": true, "1h": true, "1d": true, "5d": true, "1wk": true, "1mo": true, "3mo": true,
}
// yahooChartQuery returns a validated range/interval query string for Yahoo's
// chart endpoint, or "" when the caller supplies neither (preserving the intraday
// default). Only whitelisted values pass through.
func yahooChartQuery(r *http.Request) string {
parts := make([]string, 0, 2)
if rng := lower(trimSpace(r.URL.Query().Get("range"))); yahooRanges[rng] {
parts = append(parts, "range="+rng)
}
if itv := lower(trimSpace(r.URL.Query().Get("interval"))); yahooIntervals[itv] {
parts = append(parts, "interval="+itv)
}
return strings.Join(parts, "&")
}
func validateSymbol(sym string) string {
sym = upper(trimSpace(sym))
if sym == "" || len(sym) > 20 {
+5
View File
@@ -31,6 +31,11 @@ import (
type wIdentity struct {
Org string `json:"owner"`
Sub string `json:"sub"`
// Admin is IAM's own owner/admin flag for the identity, honored when the
// userinfo carries it — so an org's admin may publish that org's shared doc
// even when the org is not a global-admin org. Absent claim → false (the
// global-admin-org gate still applies). See isOrgAdmin.
Admin bool `json:"isAdmin"`
}
// introspectIdentity resolves the caller's org (owner claim) + subject from IAM
+468
View File
@@ -0,0 +1,468 @@
package world
import (
"context"
"net/http"
"sort"
"strings"
"sync"
"time"
"github.com/hanzoai/world/internal/world/mcp"
)
// handlers_worldgw.go implements the six single-domain AI-plane endpoints the
// world-gw MCP/ZAP gateway proxies (conflicts, infra, vessel, news, markets,
// feeds). Each is a THIN reshape over an EXISTING backend source — the same
// producers the event aggregator uses (handlers_events.go) or the same cache
// key/fetcher a sibling handler uses — never a second fetcher for the same data.
// All are public reads, mirroring their siblings; honest empty + a "note" field
// wherever a source is genuinely not provisioned server-side.
// ── /v1/world/conflicts ──────────────────────────────────────────────────────
// handleConflicts filters the UCDP GED conflict events (the same conflictRecords
// the aggregator emits) by country / since / severity.
func (s *Server) handleConflicts(w http.ResponseWriter, r *http.Request) {
if preflight(w, r, "GET, OPTIONS") || methodNotGet(w, r) {
return
}
ctx, cancel := context.WithTimeout(r.Context(), 30*time.Second)
defer cancel()
q := r.URL.Query()
country := strings.ToLower(strings.TrimSpace(q.Get("country")))
severity := strings.ToLower(strings.TrimSpace(q.Get("severity")))
since := parseAnyTime(q.Get("since"))
out := make([]map[string]any, 0, 64)
for _, rec := range s.conflictRecords(ctx) {
if !since.IsZero() && rec.TS.Before(since) {
continue
}
if severity != "" && asString(mapGet(rec.Payload, "severity")) != severity {
continue
}
if country != "" && !strings.Contains(strings.ToLower(asString(mapGet(rec.Payload, "country"))), country) {
continue
}
out = append(out, rec.Payload)
}
resp := map[string]any{"conflicts": out, "count": len(out), "source": "ucdp-ged", "asOf": nowISO()}
if country != "" && len(out) == 0 {
resp["note"] = "country matches on the UCDP country NAME (substring, case-insensitive); an ISO code may not match"
}
writeJSON(w, http.StatusOK, "public, max-age=300, s-maxage=300, stale-while-revalidate=60", resp)
}
// ── /v1/world/infra ──────────────────────────────────────────────────────────
var serviceCategorySet = map[string]bool{"cloud": true, "dev": true, "comm": true, "ai": true, "saas": true}
var physicalInfraSet = map[string]bool{"cable": true, "base": true, "nuclear": true, "power": true, "telecom": true}
// handleInfra returns the real provider/service infrastructure-status board (the
// same data /v1/world/service-status serves, one fetcher). Physical-asset
// geolocation (cables/bases/nuclear/power/telecom) and the geospatial 'near'
// filter are not provisioned server-side — those get an honest empty + note.
func (s *Server) handleInfra(w http.ResponseWriter, r *http.Request) {
if preflight(w, r, "GET, OPTIONS") || methodNotGet(w, r) {
return
}
q := r.URL.Query()
typ := strings.ToLower(strings.TrimSpace(q.Get("type")))
near := strings.TrimSpace(q.Get("near"))
if physicalInfraSet[typ] {
writeJSON(w, http.StatusOK, "public, max-age=60", map[string]any{
"infrastructure": []any{}, "type": typ,
"note": "physical-infrastructure geodata (cables, bases, nuclear, power, telecom) is not provisioned server-side; /v1/world/infra surfaces provider/service infrastructure status",
})
return
}
category := ""
note := ""
if serviceCategorySet[typ] {
category = typ
} else if typ != "" {
note = "unknown type " + typ + "; returning the full provider/service infrastructure board. "
}
ctx, cancel := context.WithTimeout(r.Context(), 30*time.Second)
defer cancel()
v, ok := s.snapshotJSON(ctx, "service-status:"+category, time.Minute, 10*time.Minute,
func(c context.Context) (any, error) { return s.serviceStatusBoard(c, category) })
board := mapOf(v)
services := sliceOfMaps(mapGet(board, "services"))
if services == nil {
services = []map[string]any{}
}
if near != "" {
note += "geospatial 'near' filter unsupported: server-side infrastructure is provider-status, not geolocated"
}
resp := map[string]any{
"infrastructure": services,
"summary": mapGet(board, "summary"),
"type": typ,
"count": len(services),
"asOf": nowISO(),
}
if note != "" {
resp["note"] = strings.TrimSpace(note)
}
if !ok {
resp["note"] = strings.TrimSpace("service-status upstream unavailable. " + note)
}
writeJSON(w, http.StatusOK, "public, max-age=60, s-maxage=60, stale-while-revalidate=30", resp)
}
// ── /v1/world/vessel ─────────────────────────────────────────────────────────
// handleVessel looks a vessel up in the AIS relay snapshot by mmsi / imo / name.
// The relay is optional (WS_RELAY_URL); when it is not configured — the common
// case server-side — this returns an honest empty set with a note rather than
// fabricated positions.
func (s *Server) handleVessel(w http.ResponseWriter, r *http.Request) {
if preflight(w, r, "GET, OPTIONS") || methodNotGet(w, r) {
return
}
base := relayBase()
if base == "" {
writeJSON(w, http.StatusOK, "public, max-age=30", map[string]any{
"vessels": []any{}, "note": "AIS tracking not provisioned server-side",
})
return
}
q := r.URL.Query()
mmsi := strings.TrimSpace(q.Get("mmsi"))
imo := strings.TrimSpace(q.Get("imo"))
name := strings.ToLower(strings.TrimSpace(q.Get("name")))
ctx, cancel := context.WithTimeout(r.Context(), 15*time.Second)
defer cancel()
var snap map[string]any
if err := s.getJSON(ctx, base+"/ais/snapshot", map[string]string{"Accept": "application/json"}, &snap); err != nil {
writeJSON(w, http.StatusOK, "no-store", map[string]any{"vessels": []any{}, "note": "AIS relay unreachable"})
return
}
out := make([]map[string]any, 0, 8)
for _, ves := range sliceOfMaps(mapGet(snap, "vessels")) {
if mmsi != "" && asString(mapGet(ves, "mmsi")) != mmsi {
continue
}
if imo != "" && asString(mapGet(ves, "imo")) != imo {
continue
}
if name != "" && !strings.Contains(strings.ToLower(asString(mapGet(ves, "name"))), name) {
continue
}
out = append(out, ves)
}
writeJSON(w, http.StatusOK, "public, max-age=8, s-maxage=8", map[string]any{"vessels": out, "count": len(out)})
}
// ── /v1/world/news ───────────────────────────────────────────────────────────
// newsCategoryAlias maps the gw's news vocabulary (world|markets|tech|finance|
// happy) onto the curated feed categories (mcp.FeedCategories). Unknowns fall
// back to "world".
var newsCategoryAlias = map[string]string{
"finance": "markets", "happy": "world", "security": "security", "ai": "ai",
"world": "world", "markets": "markets", "tech": "tech",
}
// handleNews returns the latest curated-feed headlines for one category — the
// same feed pipeline the aggregator's world.news.live producer uses.
func (s *Server) handleNews(w http.ResponseWriter, r *http.Request) {
if preflight(w, r, "GET, OPTIONS") || methodNotGet(w, r) {
return
}
q := r.URL.Query()
category := newsCategoryAlias[strings.ToLower(strings.TrimSpace(q.Get("category")))]
if category == "" {
category = "world"
}
limit := clampInt(q.Get("limit"), 20, 1, 100)
ctx, cancel := context.WithTimeout(r.Context(), 25*time.Second)
defer cancel()
items := s.newsSnapshot(ctx, category, limit)
writeJSON(w, http.StatusOK, "public, max-age=60, s-maxage=60, stale-while-revalidate=300",
map[string]any{"items": items, "category": category, "count": len(items), "asOf": nowISO()})
}
// newsSnapshot fetches the curated feeds for one category through the SHARED feed
// pipeline (feedXML warm cache → parseFeedItems → enrichFeedItems), merges them
// newest-first and caps to limit. Cached briefly under a per-category key so the
// stream loop and repeated polls do not re-parse every call.
func (s *Server) newsSnapshot(ctx context.Context, category string, limit int) []feedBatchItem {
cats := mcp.FeedCategories()
urls, ok := cats[category]
if !ok {
category, urls = "world", cats["world"]
}
key := "aiplane:news:" + category
if v, hit := s.cache.Get(key); hit {
if items, ok := v.([]feedBatchItem); ok {
return capNews(items, limit)
}
}
merged := make([]feedBatchItem, 0, len(urls)*feedsBatchMaxItems)
var mu sync.Mutex
var wg sync.WaitGroup
for _, u := range urls {
wg.Add(1)
go func(u string) {
defer wg.Done()
body, ok, fresh := s.feedXML(ctx, u)
if !ok {
return
}
items := enrichFeedItems(parseFeedItems(body, feedsBatchMaxItems), "full")
mu.Lock()
merged = append(merged, items...)
mu.Unlock()
if fresh {
s.ingestFeedItems(u, body)
}
}(u)
}
wg.Wait()
sort.SliceStable(merged, func(i, j int) bool {
return parseAnyTime(merged[i].PubDate).After(parseAnyTime(merged[j].PubDate))
})
s.cache.Set(key, merged, 2*time.Minute, 10*time.Minute)
return capNews(merged, limit)
}
func capNews(items []feedBatchItem, limit int) []feedBatchItem {
if limit > 0 && len(items) > limit {
return items[:limit]
}
return items
}
// ── /v1/world/markets ────────────────────────────────────────────────────────
// handleMarkets returns quotes / crypto. Equities+FX+commodities come from Yahoo
// (quotesSnapshot); crypto from CoinGecko (cryptoSnapshot) — the same producers
// the aggregator projects onto world.markets.{quotes,crypto}.
func (s *Server) handleMarkets(w http.ResponseWriter, r *http.Request) {
if preflight(w, r, "GET, OPTIONS") || methodNotGet(w, r) {
return
}
q := r.URL.Query()
ticker := strings.TrimSpace(q.Get("ticker"))
category := strings.ToLower(strings.TrimSpace(q.Get("category")))
ctx, cancel := context.WithTimeout(r.Context(), 30*time.Second)
defer cancel()
resp := map[string]any{"category": category, "asOf": nowISO()}
if ticker != "" {
if category == "crypto" {
resp["crypto"] = s.cryptoSnapshot(ctx, ticker)
} else {
cat := category
if !oneOf(cat, "fx", "commodities", "equities") {
cat = "equities"
}
resp["quotes"] = s.quotesSnapshot(ctx, cat, ticker)
}
writeJSON(w, http.StatusOK, marketsCC, resp)
return
}
switch category {
case "crypto":
resp["crypto"] = s.cryptoSnapshot(ctx, "")
case "fx", "commodities", "equities":
resp["quotes"] = s.quotesSnapshot(ctx, category, "")
default: // "" / "all" → both equities and crypto
resp["quotes"] = s.quotesSnapshot(ctx, "equities", "")
resp["crypto"] = s.cryptoSnapshot(ctx, "")
}
writeJSON(w, http.StatusOK, marketsCC, resp)
}
const marketsCC = "public, max-age=60, s-maxage=60, stale-while-revalidate=60"
type symInfo struct{ sym, name string }
var quoteSets = map[string][]symInfo{
"equities": {{"^GSPC", "S&P 500"}, {"^IXIC", "Nasdaq Composite"}, {"^DJI", "Dow Jones"}, {"^FTSE", "FTSE 100"}, {"^N225", "Nikkei 225"}, {"^GDAXI", "DAX"}},
"fx": {{"EURUSD=X", "EUR/USD"}, {"GBPUSD=X", "GBP/USD"}, {"USDJPY=X", "USD/JPY"}, {"USDCNY=X", "USD/CNY"}},
"commodities": {{"GC=F", "Gold"}, {"CL=F", "WTI Crude"}, {"BZ=F", "Brent Crude"}, {"SI=F", "Silver"}, {"NG=F", "Natural Gas"}},
}
// quotesSnapshot fetches Yahoo chart quotes for a category's symbol set (or a
// single ticker), computing price + intraday % change from the last two closes.
// Cached briefly per (category|ticker). Reuses the shared s.yahooChart fetcher.
func (s *Server) quotesSnapshot(ctx context.Context, category, ticker string) []map[string]any {
set := quoteSets[category]
if set == nil {
set = quoteSets["equities"]
}
key := "aiplane:quotes:" + category
if ticker != "" {
set = []symInfo{{upper(ticker), upper(ticker)}}
key = "aiplane:quote:" + upper(ticker)
}
if v, hit := s.cache.Get(key); hit {
if out, ok := v.([]map[string]any); ok {
return out
}
}
out := make([]map[string]any, len(set))
var wg sync.WaitGroup
for i, si := range set {
wg.Add(1)
go func(i int, si symInfo) {
defer wg.Done()
out[i] = s.oneQuote(ctx, si, category)
}(i, si)
}
wg.Wait()
final := make([]map[string]any, 0, len(out))
for _, q := range out {
if q != nil {
final = append(final, q)
}
}
if len(final) > 0 {
s.cache.Set(key, final, 2*time.Minute, 10*time.Minute)
}
return final
}
func (s *Server) oneQuote(ctx context.Context, si symInfo, category string) map[string]any {
yc, err := s.yahooChart(ctx, si.sym, "range=5d&interval=1d")
if err != nil {
return nil
}
closes := yc.closes()
if len(closes) < 2 {
return nil
}
last, prev := closes[len(closes)-1], closes[len(closes)-2]
change := 0.0
if prev != 0 {
change = (last - prev) / prev * 100
}
cur := "USD"
if len(yc.Chart.Result) > 0 && yc.Chart.Result[0].Meta.Currency != "" {
cur = yc.Chart.Result[0].Meta.Currency
}
cat := category
if cat == "" {
cat = "equities"
}
return map[string]any{
"symbol": si.sym, "name": si.name, "price": round2s(last),
"changePercent": round2s(change), "currency": cur, "category": cat, "source": "yahoo",
}
}
var cryptoDefault = []struct{ id, sym, name string }{
{"bitcoin", "BTC", "Bitcoin"}, {"ethereum", "ETH", "Ethereum"}, {"solana", "SOL", "Solana"},
{"binancecoin", "BNB", "BNB"}, {"ripple", "XRP", "XRP"}, {"cardano", "ADA", "Cardano"},
{"dogecoin", "DOGE", "Dogecoin"}, {"tron", "TRX", "TRON"}, {"chainlink", "LINK", "Chainlink"},
{"avalanche-2", "AVAX", "Avalanche"},
}
// cryptoSnapshot fetches CoinGecko simple-price for the default coin set (or a
// single ticker/id), returning {symbol,id,name,price,change24h} rows. Cached
// briefly. Mirrors sourceMarkets' CoinGecko fetch — no second crypto fetcher.
func (s *Server) cryptoSnapshot(ctx context.Context, ticker string) []map[string]any {
coins := cryptoDefault
key := "aiplane:crypto"
if ticker != "" {
coins = []struct{ id, sym, name string }{resolveCoin(ticker)}
key = "aiplane:crypto:" + coins[0].id
}
if v, hit := s.cache.Get(key); hit {
if out, ok := v.([]map[string]any); ok {
return out
}
}
ids := make([]string, len(coins))
for i, c := range coins {
ids[i] = c.id
}
u := "https://api.coingecko.com/api/v3/simple/price?ids=" + urlQueryEscape(joinComma(ids)) +
"&vs_currencies=usd&include_24hr_change=true"
var raw map[string]struct {
USD float64 `json:"usd"`
Change float64 `json:"usd_24h_change"`
}
if err := s.getJSON(ctx, u, map[string]string{"Accept": "application/json"}, &raw); err != nil {
if v, hit := s.cache.GetStale(key); hit {
if out, ok := v.([]map[string]any); ok {
return out
}
}
return nil
}
out := make([]map[string]any, 0, len(coins))
for _, c := range coins {
d, ok := raw[c.id]
if !ok {
continue
}
out = append(out, map[string]any{
"symbol": c.sym, "id": c.id, "name": c.name,
"price": round2s(d.USD), "change24h": round2s(d.Change), "category": "crypto", "source": "coingecko",
})
}
if len(out) > 0 {
s.cache.Set(key, out, 2*time.Minute, 10*time.Minute)
}
return out
}
// resolveCoin maps a ticker/id to a CoinGecko id + display symbol, using the
// default roster when it recognises the input, else treating it as a raw id.
func resolveCoin(ticker string) struct{ id, sym, name string } {
t := strings.ToLower(strings.TrimSpace(ticker))
for _, c := range cryptoDefault {
if c.id == t || strings.ToLower(c.sym) == t {
return c
}
}
return struct{ id, sym, name string }{id: t, sym: strings.ToUpper(t), name: ticker}
}
// ── /v1/world/feeds ──────────────────────────────────────────────────────────
// topicSource documents which real server-side source backs each ZAP topic (""
// = not provisioned server-side, i.e. the aggregator emits nothing for it).
var topicSource = map[string]string{
topicAll: "aggregate of every server-side event topic",
topicConflicts: "UCDP GED (/v1/world/ucdp-events)",
topicEarthquakes: "USGS 4.5+/day (/v1/world/earthquakes)",
topicFires: "NASA FIRMS (/v1/world/firms-fires; emitted when the fires layer is active)",
topicQuotes: "Yahoo Finance indices",
topicCrypto: "CoinGecko",
topicAIS: "", // AIS relay is client-side / not provisioned server-side
topicOpenSky: "", // OpenSky live states are not aggregated onto the event plane
topicNews: "curated RSS feeds (/v1/world/feeds-batch)",
topicWeather: "Open-Meteo climate anomalies (/v1/world/climate-anomalies; emitted when the climate layer is active)",
}
// handleFeeds returns the feed/topic catalog: the canonical ZAP topics (with the
// real source backing each, and whether it is emitted server-side) plus the
// backend's curated news feed categories.
func (s *Server) handleFeeds(w http.ResponseWriter, r *http.Request) {
if preflight(w, r, "GET, OPTIONS") || methodNotGet(w, r) {
return
}
topics := make([]map[string]any, 0, len(zapTopics))
for _, t := range zapTopics {
src := topicSource[t]
topics = append(topics, map[string]any{"topic": t, "emitted": src != "", "source": src})
}
writeJSON(w, http.StatusOK, "public, max-age=300, s-maxage=300, stale-while-revalidate=60", map[string]any{
"topics": topics,
"feedCategories": mcp.FeedCategoryNames(),
"layers": []string{"conflicts", "earthquakes", "fires", "weather", "news", "quotes", "crypto"},
"asOf": nowISO(),
})
}
+214
View File
@@ -0,0 +1,214 @@
package world
import (
"net/http"
"testing"
"github.com/hanzoai/world/internal/world/mcp"
)
// The six single-domain AI-plane endpoints are thin reshapes over existing
// sources. Each test seeds the underlying source's cache (or, for news, the feed
// cache) so the handler resolves offline, and asserts the documented shape.
func TestConflictsEndpointFilters(t *testing.T) {
s := gwServer(t)
seedSources(s) // e1 Ukraine (30 deaths=critical), e2 Sudan (3 deaths=minor)
// No filter → both events, honest source tag.
code, body := getJSONBody(t, s.handleConflicts, "/v1/world/conflicts")
if code != 200 {
t.Fatalf("status = %d", code)
}
if body["source"] != "ucdp-ged" {
t.Fatalf("source = %v", body["source"])
}
if got := len(body["conflicts"].([]any)); got != 2 {
t.Fatalf("conflicts = %d, want 2", got)
}
// severity=critical keeps only the 30-death event.
_, body = getJSONBody(t, s.handleConflicts, "/v1/world/conflicts?severity=critical")
crit := body["conflicts"].([]any)
if len(crit) != 1 || crit[0].(map[string]any)["country"] != "Ukraine" {
t.Fatalf("severity=critical = %v", crit)
}
// country substring (case-insensitive) on the UCDP name.
_, body = getJSONBody(t, s.handleConflicts, "/v1/world/conflicts?country=sudan")
if got := len(body["conflicts"].([]any)); got != 1 {
t.Fatalf("country=sudan = %d, want 1", got)
}
// A non-matching country yields an honest empty + note (not a 5xx / fabrication).
_, body = getJSONBody(t, s.handleConflicts, "/v1/world/conflicts?country=ZZ")
if len(body["conflicts"].([]any)) != 0 || body["note"] == nil {
t.Fatalf("country=ZZ = %v (note=%v)", body["conflicts"], body["note"])
}
}
func TestInfraEndpoint(t *testing.T) {
s := gwServer(t)
seedCache(s, "service-status:", map[string]any{
"success": true, "timestamp": nowISO(),
"summary": map[string]int{"operational": 1, "degraded": 0, "outage": 0, "unknown": 0},
"services": []map[string]any{{"id": "aws", "name": "AWS", "category": "cloud", "status": "operational", "description": "ok"}},
})
// Default → the real provider/service board.
code, body := getJSONBody(t, s.handleInfra, "/v1/world/infra")
if code != 200 {
t.Fatalf("status = %d", code)
}
if got := len(body["infrastructure"].([]any)); got != 1 {
t.Fatalf("infrastructure = %d, want 1", got)
}
// A physical-infra type has no server-side geodata → honest empty + note.
_, body = getJSONBody(t, s.handleInfra, "/v1/world/infra?type=cable")
if len(body["infrastructure"].([]any)) != 0 || body["note"] == nil {
t.Fatalf("type=cable = %v (note=%v)", body["infrastructure"], body["note"])
}
// 'near' is unsupported → still 200, with a note.
_, body = getJSONBody(t, s.handleInfra, "/v1/world/infra?near=48.85,2.35,50")
if body["note"] == nil {
t.Fatalf("near should carry a note, got %v", body)
}
}
func TestVesselHonestEmptyWithoutRelay(t *testing.T) {
s := gwServer(t) // WS_RELAY_URL unset → relay not configured
code, body := getJSONBody(t, s.handleVessel, "/v1/world/vessel?mmsi=123456789")
if code != 200 {
t.Fatalf("status = %d", code)
}
if len(body["vessels"].([]any)) != 0 {
t.Fatalf("vessels = %v, want empty", body["vessels"])
}
if body["note"] != "AIS tracking not provisioned server-side" {
t.Fatalf("note = %v", body["note"])
}
}
func TestNewsEndpoint(t *testing.T) {
s := gwServer(t)
rss := []byte(`<?xml version="1.0"?><rss><channel>` +
`<item><title>Test world headline</title><link>https://example.com/a</link><pubDate>Mon, 20 Jul 2026 10:00:00 GMT</pubDate></item>` +
`</channel></rss>`)
for _, u := range mcp.FeedCategories()["world"] {
s.feeds.Put(u, rss) // warm the feed cache so feedXML never hits the network
}
code, body := getJSONBody(t, s.handleNews, "/v1/world/news?category=world&limit=5")
if code != 200 {
t.Fatalf("status = %d", code)
}
if body["category"] != "world" {
t.Fatalf("category = %v", body["category"])
}
items := body["items"].([]any)
if len(items) == 0 {
t.Fatalf("items empty; feed pipeline did not resolve")
}
if items[0].(map[string]any)["title"] != "Test world headline" {
t.Fatalf("item title = %v", items[0])
}
// The gw alias finance→markets must resolve (no crash, valid category).
_, body = getJSONBody(t, s.handleNews, "/v1/world/news?category=finance")
if body["category"] != "markets" {
t.Fatalf("finance should alias to markets, got %v", body["category"])
}
}
func TestMarketsEndpoint(t *testing.T) {
s := gwServer(t)
seedCache(s, "aiplane:crypto", []map[string]any{
{"symbol": "BTC", "id": "bitcoin", "name": "Bitcoin", "price": 65000.0, "change24h": 1.5, "category": "crypto", "source": "coingecko"},
})
seedCache(s, "aiplane:quotes:equities", []map[string]any{
{"symbol": "^GSPC", "name": "S&P 500", "price": 5000.0, "changePercent": 0.5, "currency": "USD", "category": "equities", "source": "yahoo"},
})
// Default → both quotes and crypto.
code, body := getJSONBody(t, s.handleMarkets, "/v1/world/markets")
if code != 200 {
t.Fatalf("status = %d", code)
}
if len(body["quotes"].([]any)) != 1 || len(body["crypto"].([]any)) != 1 {
t.Fatalf("default markets = %v", body)
}
// category=crypto → crypto only, no quotes key.
_, body = getJSONBody(t, s.handleMarkets, "/v1/world/markets?category=crypto")
if _, hasQuotes := body["quotes"]; hasQuotes {
t.Fatalf("category=crypto must not include quotes: %v", body)
}
if len(body["crypto"].([]any)) != 1 {
t.Fatalf("category=crypto crypto = %v", body["crypto"])
}
}
func TestFeedsCatalog(t *testing.T) {
s := gwServer(t)
code, body := getJSONBody(t, s.handleFeeds, "/v1/world/feeds")
if code != 200 {
t.Fatalf("status = %d", code)
}
topics := body["topics"].([]any)
if len(topics) != len(zapTopics) {
t.Fatalf("topics count = %d, want %d", len(topics), len(zapTopics))
}
emitted := map[string]bool{}
for _, tp := range topics {
m := tp.(map[string]any)
emitted[m["topic"].(string)] = m["emitted"].(bool)
}
// A backed topic is emitted; a not-provisioned one is honestly flagged false.
if !emitted[topicConflicts] {
t.Errorf("conflicts should be emitted")
}
if emitted[topicAIS] || emitted[topicOpenSky] {
t.Errorf("ais/opensky are not server-side sources; must be emitted=false")
}
if _, ok := body["feedCategories"].([]any); !ok {
t.Fatalf("feedCategories missing: %v", body["feedCategories"])
}
}
// Every AI-plane endpoint must reject a non-GET and ignore junk params gracefully.
func TestAIPlaneMethodAndJunkParams(t *testing.T) {
s := gwServer(t)
seedSources(s)
seedCache(s, "service-status:", map[string]any{"services": []map[string]any{}, "summary": map[string]int{}})
handlers := map[string]http.HandlerFunc{
"/v1/world/conflicts": s.handleConflicts,
"/v1/world/infra": s.handleInfra,
"/v1/world/vessel": s.handleVessel,
"/v1/world/news": s.handleNews,
"/v1/world/markets": s.handleMarkets,
"/v1/world/feeds": s.handleFeeds,
}
for path, h := range handlers {
// junk params → still a clean 200.
if code, _ := getJSONBody(t, h, path+"?bogus=1&foo=bar&limit=abc"); code != 200 {
t.Errorf("%s with junk params: status = %d, want 200", path, code)
}
}
// The route registrar wires every AI-plane path so the mux serves them (not the
// SPA catch-all).
want := []string{"/v1/world/events", "/v1/world/conflicts", "/v1/world/infra",
"/v1/world/vessel", "/v1/world/news", "/v1/world/markets", "/v1/world/feeds"}
have := map[string]bool{}
for _, p := range s.Routes() {
have[p] = true
}
for _, p := range want {
if !have[p] {
t.Errorf("route %s not registered", p)
}
}
}
@@ -0,0 +1,23 @@
package world
import (
"net/http/httptest"
"testing"
)
func TestYahooChartQuery(t *testing.T) {
cases := []struct{ q, want string }{
{"?symbol=SMH&range=1y&interval=1d", "range=1y&interval=1d"},
{"?symbol=SMH", ""}, // default preserved
{"?symbol=SMH&range=bogus&interval=1d", "interval=1d"}, // bad range dropped
{"?symbol=SMH&range=1Y&interval=1D", "range=1y&interval=1d"}, // case-normalized
{"?symbol=SMH&range=5y", "range=5y"},
{"?symbol=SMH&interval=evil", ""}, // bad interval dropped
}
for _, c := range cases {
r := httptest.NewRequest("GET", "/v1/world/yahoo-finance"+c.q, nil)
if got := yahooChartQuery(r); got != c.want {
t.Errorf("yahooChartQuery(%q) = %q, want %q", c.q, got, c.want)
}
}
}
+22
View File
@@ -277,6 +277,28 @@ var feedCategories = map[string][]string{
// feedCategoryNames is the ordered enum for the feeds tool schema (deterministic).
var feedCategoryNames = []string{"world", "tech", "markets", "security", "ai"}
// FeedCategories returns a copy of the curated category→feed-URL map so
// same-binary callers (the AI-plane /v1/world/{news,feeds} handlers) read the
// EXACT curated list the feeds tool exposes — one source of truth, no second
// feed table. Copied so a caller can never mutate the registry.
func FeedCategories() map[string][]string {
out := make(map[string][]string, len(feedCategories))
for k, v := range feedCategories {
cp := make([]string, len(v))
copy(cp, v)
out[k] = cp
}
return out
}
// FeedCategoryNames returns the ordered category enum (a copy) for the same
// callers, so the /v1/world/feeds catalog lists categories in registry order.
func FeedCategoryNames() []string {
out := make([]string, len(feedCategoryNames))
copy(out, feedCategoryNames)
return out
}
// ── schema + argument helpers ────────────────────────────────────────────────
func objectSchema(required []string, props map[string]any) map[string]any {
@@ -6,7 +6,8 @@ package world
// is fail-closed across the full matrix Blue left "network-bound"/untested:
// - no token → 401
// - non-admin owner → 403
// - empty/missing owner → 403 (NOT admin, even though isAdminOrg is not env-driven)
// - empty/missing owner → 403 (base predicate is a fixed literal)
// - operator org via WORLD_ADMIN_ORGS → gate passes; the same owner 403s without it
// - IAM 401 / 500 / non-JSON 200 → 403 (introspection failure)
// - admin / built-in owner → gate PASSES (not 401/403)
// - owner with surrounding whitespace → trimmed, PASSES
@@ -52,15 +53,20 @@ func TestRedAdminGateMatrix(t *testing.T) {
const adminRoute = "/v1/world/cloud/fleet"
cases := []struct {
name string
iamStatus int
iamBody string
bearer string // "" = send no Authorization header
wantStatus int // exact status when deterministic
gatePass bool // true = assert NOT 401 and NOT 403 (downstream may vary)
name string
iamStatus int
iamBody string
bearer string // "" = send no Authorization header
adminOrgsEnv string // WORLD_ADMIN_ORGS for this case ("" = base {admin,built-in})
wantStatus int // exact status when deterministic
gatePass bool // true = assert NOT 401 and NOT 403 (downstream may vary)
}{
{name: "no token → 401", bearer: "", wantStatus: http.StatusUnauthorized},
{name: "non-admin owner → 403", iamStatus: 200, iamBody: `{"owner":"acme","sub":"u1"}`, bearer: "Bearer good", wantStatus: http.StatusForbidden},
// Operator org is opt-in via WORLD_ADMIN_ORGS (deploy env), additive to the
// canonical {admin,built-in} base — never hardcoded in the predicate.
{name: "operator org via WORLD_ADMIN_ORGS → gate passes", iamStatus: 200, iamBody: `{"owner":"hanzo","sub":"z"}`, bearer: "Bearer good", adminOrgsEnv: "hanzo", gatePass: true},
{name: "operator org WITHOUT env → 403", iamStatus: 200, iamBody: `{"owner":"hanzo","sub":"z"}`, bearer: "Bearer good", wantStatus: http.StatusForbidden},
{name: "empty owner → 403", iamStatus: 200, iamBody: `{"owner":"","sub":"u1"}`, bearer: "Bearer good", wantStatus: http.StatusForbidden},
{name: "missing owner claim → 403", iamStatus: 200, iamBody: `{"sub":"u1"}`, bearer: "Bearer good", wantStatus: http.StatusForbidden},
{name: "owner=null → 403", iamStatus: 200, iamBody: `{"owner":null,"sub":"u1"}`, bearer: "Bearer good", wantStatus: http.StatusForbidden},
@@ -81,6 +87,7 @@ func TestRedAdminGateMatrix(t *testing.T) {
api := apiStub(t)
t.Setenv("HANZO_IAM_ISSUER", iam.URL)
t.Setenv("HANZO_API_BASE", api.URL)
t.Setenv("WORLD_ADMIN_ORGS", tc.adminOrgsEnv)
s := NewServer()
mux := http.NewServeMux()
@@ -151,16 +158,20 @@ func TestRedSharedCacheNoElevation(t *testing.T) {
}
}
// TestRedIsAdminOrgExact locks the admin-org predicate: only the two hardcoded
// orgs (trimmed) qualify; empty never does. This is the property that makes the
// "empty ADMIN_ORG == empty owner" class of bug structurally impossible — there
// is no env-driven admin org to leave unset.
// TestRedIsAdminOrgExact locks the admin-org predicate: the canonical base is
// EXACTLY {admin, built-in} (trimmed), matching cloud's globalAdminOrgs — never
// case/substring variants, never empty. The base is a fixed literal, NOT
// env-driven, so the "empty ADMIN_ORG == empty owner" bug class is impossible.
// An operator org is additive-only via WORLD_ADMIN_ORGS (deploy env): denied by
// default, admitted when the env names it — config, not a hardcoded widening.
func TestRedIsAdminOrgExact(t *testing.T) {
t.Setenv("WORLD_ADMIN_ORGS", "") // isolate: base predicate only
admit := map[string]bool{
"admin": true, "built-in": true,
" admin ": true, "\tbuilt-in\n": true,
}
deny := []string{"", " ", "Admin", "ADMIN", "administrator", "built_in", "builtin", "admins", "acme", "\x00admin"}
// "hanzo" is the operator org — NOT admin by default (only via WORLD_ADMIN_ORGS).
deny := []string{"", " ", "Admin", "ADMIN", "administrator", "built_in", "builtin", "admins", "acme", "hanzo", "\x00admin"}
for in, want := range admit {
if isAdminOrg(in) != want {
t.Fatalf("isAdminOrg(%q) = %v, want %v", in, !want, want)
@@ -171,4 +182,18 @@ func TestRedIsAdminOrgExact(t *testing.T) {
t.Fatalf("isAdminOrg(%q) = true, want false", in)
}
}
// Env-driven operator org: additive to the base, still exact (no widening of
// the case/substring rules, base orgs unaffected).
t.Setenv("WORLD_ADMIN_ORGS", "hanzo, ops")
for _, in := range []string{"hanzo", "ops", "admin", "built-in", " hanzo "} {
if !isAdminOrg(in) {
t.Fatalf("with WORLD_ADMIN_ORGS set, isAdminOrg(%q) = false, want true", in)
}
}
for _, in := range []string{"", "Hanzo", "acme", "op"} {
if isAdminOrg(in) {
t.Fatalf("with WORLD_ADMIN_ORGS set, isAdminOrg(%q) = true, want false", in)
}
}
}
+40
View File
@@ -23,6 +23,18 @@ func (s *Server) mount(mux registrar) {
mux.HandleFunc("/v1/world/version", s.handleVersion)
mux.HandleFunc("/v1/world/download", s.handleDownload)
// AI plane — the aggregate + single-domain read surface the world-gw MCP/ZAP
// gateway (world-zap) proxies. /events is both the snapshot poll and (?stream=1)
// the long-lived NDJSON topic stream; the rest are thin reshapes over the same
// producers / sources. All PUBLIC reads (see handlers_events.go, handlers_worldgw.go).
mux.HandleFunc("/v1/world/events", s.handleEvents)
mux.HandleFunc("/v1/world/conflicts", s.handleConflicts)
mux.HandleFunc("/v1/world/infra", s.handleInfra)
mux.HandleFunc("/v1/world/vessel", s.handleVessel)
mux.HandleFunc("/v1/world/news", s.handleNews)
mux.HandleFunc("/v1/world/markets", s.handleMarkets)
mux.HandleFunc("/v1/world/feeds", s.handleFeeds)
// conflict
mux.HandleFunc("/v1/world/acled", s.handleACLED)
mux.HandleFunc("/v1/world/acled-conflict", s.handleACLEDConflict)
@@ -41,8 +53,25 @@ func (s *Server) mount(mux registrar) {
mux.HandleFunc("/v1/world/etf-flows", s.handleETFFlows)
mux.HandleFunc("/v1/world/macro-signals", s.handleMacroSignals)
mux.HandleFunc("/v1/world/rotation", s.handleRotation)
// Autonomous multi-asset fund brain (PAPER-only): the full conviction book,
// the paper ledger the autonomous engine writes, and a deterministic daily
// brief. Exact paths beat the /v1/world/ catch-all. No real orders — ever.
mux.HandleFunc("/v1/world/fund", s.handleFund)
mux.HandleFunc("/v1/world/fund/ledger", s.handleFundLedger)
mux.HandleFunc("/v1/world/fund/brief", s.handleFundBrief)
mux.HandleFunc("/v1/world/indicators", s.handleIndicators)
mux.HandleFunc("/v1/world/sentiment", s.handleSentiment)
mux.HandleFunc("/v1/world/defi", s.handleDefi)
mux.HandleFunc("/v1/world/insider", s.handleInsider)
mux.HandleFunc("/v1/world/layoffs", s.handleLayoffs)
mux.HandleFunc("/v1/world/congress", s.handleCongress)
// alt assets — art/collectibles auction results (Christie's public realized
// sale totals) + luxury real-estate listings (LuxuryEstate). Scraped hourly +
// cached; honest empty {items:[]} on a source failure, never fabricated. These
// power the finance-terminal AltFeed panels (src/components/finance/AltFeedPanel.ts).
mux.HandleFunc("/v1/world/auctions", s.handleAuctions)
mux.HandleFunc("/v1/world/luxury-realestate", s.handleLuxuryRealestate)
// flights / geo / hazards
mux.HandleFunc("/v1/world/opensky", s.handleOpenSky)
@@ -87,6 +116,13 @@ func (s *Server) mount(mux registrar) {
// devices. Same per-identity store as settings/monitors, 'dashboard' namespace.
mux.HandleFunc("/v1/world/dashboard", s.handleDashboard)
// org-shared DASHBOARD default — the layout an org ADMIN publishes for the whole
// org. GET returns the org default to any signed-in member; PUT publishes it and
// is admin-ONLY (403 otherwise). Same opaque-blob contract as the per-user
// dashboard, keyed by org; the frontend hydrates it as the default, then the
// user's own doc overrides it.
mux.HandleFunc("/v1/world/dashboard/shared", s.handleDashboardShared)
// per-identity USAGE HISTORY — the signed-in user's real actions (recent
// searches, watch queue) persisted so they follow them across devices. Same
// per-identity store, 'history' namespace; opaque blob, never fabricated.
@@ -160,6 +196,10 @@ func (s *Server) mount(mux registrar) {
mux.HandleFunc("/v1/world/cloud/services", s.handleCloudServices)
mux.HandleFunc("/v1/world/cloud/analytics", s.handleCloudAnalytics)
mux.HandleFunc("/v1/world/cloud/llm", s.handleCloudLLM)
// DOKS cluster nodes grouped by cluster (hanzo-k8s, …) + the GPU job queue
// (gpu-jobs: depth, what's running from which service). Same requireAdmin gate.
mux.HandleFunc("/v1/world/cloud/clusters", s.handleCloudClusters)
mux.HandleFunc("/v1/world/cloud/queue", s.handleCloudQueue)
// ADMIN-only Enso benchmark suite: private, competitive head-to-head (names
// competitor models + Enso). Same requireAdmin gate (401/403 fail-closed);
// reshapes the embedded enso-bench snapshot — never leaks to a non-admin.
+11
View File
@@ -46,6 +46,7 @@ type Server struct {
ai *AIClient
worldModel *model.Engine
mcp *mcp.Server
fund *fundEngine // autonomous PAPER-only multi-asset fund brain
// Datastore layer (see datastore.go): kv is the shared hanzo-kv hot cache,
// feeds is the two-tier warm feed-body cache in front of it, and store is the
@@ -67,10 +68,20 @@ func NewServer() *Server {
mcp: mcp.New(),
}
s.worldModel = model.New(s.modelSources(), modelDataDir(), modelInterval())
// The fund brain is paper-only by construction: NewPaperBroker is the sole
// broker it is ever given. Live execution is a human-authorized action and has
// no wiring anywhere in this package (see fund_broker.go).
s.fund = newFundEngine(NewPaperBroker())
s.initDatastore()
return s
}
// StartFund begins the autonomous paper-fund loop: it recomputes the book on an
// interval, diffs it against the paper positions, and folds simulated fills into
// the ledger, until ctx is cancelled. Paper-only — the broker never moves real
// funds. Call once from main after the server is built.
func (s *Server) StartFund(ctx context.Context) { s.fund.start(ctx, s) }
// StartModel begins the world-model ingest loop; it folds once immediately then
// every interval, snapshotting to disk, until ctx is cancelled. Safe to call
// once from main after the server is built.
+7
View File
@@ -17,6 +17,13 @@ type Settings struct {
db *sql.DB // nil in degraded mode
}
// SharedSub is the reserved user_sub of an ORG-WIDE settings row — a doc owned by
// the org itself (its published default), independent of any user. No IAM subject
// equals it (subs are real IAM identifiers, never this sentinel), so a shared doc
// lives in the SAME table without ever colliding with a user's own row. Get/Put
// treat it like any identity — the org-scoped doc is just a row keyed by the org.
const SharedSub = "*org-shared*"
// Identity keys a settings row. Project defaults to "default" upstream so a
// user's single dashboard has a stable key.
type Identity struct {
+1
View File
@@ -0,0 +1 @@
<html><body><script>window.chrComponents.results={"events":[{"title_txt": "The Cellar of an Obsessive Collector Part III: Online", "subtitle_txt": "Online Auction 24946 | CLOSED", "date_display_txt": "9 \u2013 21 July", "location_txt": "Hong Kong", "sale_total_value_txt": "HKD 10,319,750", "landing_url": "https://onlineonly.christies.com/sso?SaleID=31386&SaleNumber=24946", "image": {"alt_text": "The Cellar of an Obsessive Collector Part III: Online", "sizes": [{"name": "md", "srcset": [{"width": 400, "src": "https://www.christies.com/img/SaleImages/DEMO-1.jpg?w=400"}, {"width": 800, "src": "https://www.christies.com/img/SaleImages/DEMO-1.jpg?w=800"}]}, {"name": "xl", "srcset": [{"width": 600, "src": "https://www.christies.com/img/SaleImages/DEMO-1.jpg?w=600"}]}]}}, {"title_txt": "American Beauty: Tom Wesselmann Online", "subtitle_txt": "Online Auction 24850 | CLOSED", "date_display_txt": "1 \u2013 17 July", "location_txt": "New York", "sale_total_value_txt": "USD 1,135,380", "landing_url": "https://onlineonly.christies.com/sso?SaleID=31337&SaleNumber=24850", "image": {"alt_text": "American Beauty: Tom Wesselmann Online", "sizes": [{"name": "md", "srcset": [{"width": 400, "src": "https://www.christies.com/img/SaleImages/DEMO-1.jpg?w=400"}, {"width": 800, "src": "https://www.christies.com/img/SaleImages/DEMO-1.jpg?w=800"}]}, {"name": "xl", "srcset": [{"width": 600, "src": "https://www.christies.com/img/SaleImages/DEMO-1.jpg?w=600"}]}]}}]};</script></body></html>
+1
View File
@@ -0,0 +1 @@
<html><body><script type="application/json">{"propertiesList": [{"title": "Luxury home in Banner Elk, Avery County", "label": "Luxury home", "type": "House", "url": "/p131998951-luxury-home-for-sale-banner-elk", "surface": "655 m\u00b2", "bedrooms": "5", "pictureThumb": "//pic.le-cdn.com/thumbs/520x390/04/1/properties/Property-e7240000000007de000169121ee3-131998951.jpg", "picture": "//pic.le-cdn.com/thumbs/272x204/04/1/properties/Property-e7240000000007de000169121ee3-131998951.jpg", "price": {"amount": "5,950,000", "currency": "US$ USD", "currencySymbol": "US$", "raw": 5950000}, "geoInfo": {"country": "United States", "region": "North Carolina", "province": "Avery County", "city": "Banner Elk"}}, {"title": "Luxury home in Littleton, Halifax County", "label": "Luxury home", "type": "House", "url": "/p131999422-luxury-home-for-sale-littleton", "surface": "403 m\u00b2", "bedrooms": "4", "pictureThumb": "//pic.le-cdn.com/thumbs/520x390/04/1/properties/Property-be260000000007de000169127725-131999422.jpg", "picture": "//pic.le-cdn.com/thumbs/272x204/04/1/properties/Property-be260000000007de000169127725-131999422.jpg", "price": {"amount": "2,995,000", "currency": "US$ USD", "currencySymbol": "US$", "raw": 2995000}, "geoInfo": {"country": "United States", "region": "North Carolina", "province": "Halifax County", "city": "Littleton"}}]}</script></body></html>
+8805 -377
View File
File diff suppressed because it is too large Load Diff
+19 -3
View File
@@ -1,11 +1,16 @@
{
"name": "world-monitor",
"name": "@hanzo/world",
"description": "Hanzo World — real-time global intelligence dashboard.",
"private": true,
"version": "2.4.35",
"version": "2.4.49",
"type": "module",
"scripts": {
"lint:md": "markdownlint-cli2 '**/*.md'",
"dev": "vite",
"dev:react": "vite --config vite.react.config.ts",
"build:react": "vite build --config vite.react.config.ts",
"typecheck:react": "tsc -p tsconfig.react.json --noEmit",
"test:e2e:react": "playwright test --config playwright.react.config.ts",
"dev:tech": "VITE_VARIANT=tech vite",
"dev:finance": "VITE_VARIANT=finance vite",
"build": "tsc && vite build",
@@ -50,9 +55,13 @@
"@tauri-apps/cli": "^2.10.0",
"@types/d3": "^7.4.3",
"@types/maplibre-gl": "^1.13.2",
"@types/react": "^19.2.17",
"@types/react-dom": "^19.2.3",
"@types/topojson-client": "^3.1.5",
"@types/topojson-specification": "^1.0.5",
"@vitejs/plugin-react": "^4.7.0",
"markdownlint-cli2": "^0.20.0",
"react-native": "^0.83.2",
"typescript": "^5.7.2",
"vite": "^6.0.7",
"vite-plugin-pwa": "^1.2.0",
@@ -65,7 +74,11 @@
"@deck.gl/layers": "^9.2.6",
"@deck.gl/mapbox": "^9.2.6",
"@hanzo/ai": "^0.2.1",
"@sentry/browser": "^10.39.0",
"@hanzo/brand": "^1.4.1",
"@hanzo/event": "^0.3.1",
"@hanzo/gui": "^7.3.0",
"@hanzogui/config": "^7.3.0",
"@hanzogui/shell": "^7.6.1",
"@upstash/redis": "^1.36.1",
"@xenova/transformers": "^2.17.2",
"d3": "^7.9.0",
@@ -75,6 +88,9 @@
"mapbox-gl": "^3.26.0",
"maplibre-gl": "^5.16.0",
"onnxruntime-web": "^1.23.2",
"react": "^19.2.8",
"react-dom": "^19.2.8",
"react-native-web": "^0.21.2",
"topojson-client": "^3.1.0"
}
}
+53
View File
@@ -0,0 +1,53 @@
import { defineConfig, devices } from '@playwright/test';
/**
* Playwright config for the REACT entry (index.react.html src/react/main.tsx).
*
* The shipping vanilla suite (playwright.config.ts) drives index.html through the
* tests/*.html harnesses. This is the SECOND, isolated e2e path the cutover gate
* that serves the React + @hanzo/gui surface and runs the CORE specs against it:
* globe island renders, the variant tabs switch the panel set, PanelGrid drag/reorder
* persists to the SHARED `panel-order` key, a panel's live fetch renders, and the
* unified shell/auth mounts. It is deliberately separate so `npm run test:e2e` (the
* vanilla contract) is never perturbed while the React surface is proven cutover-ready.
*
* The React dev server serves the vanilla app at `/` and the React entry at
* `/index.react.html`, so every spec here navigates to `/index.react.html`.
*/
export default defineConfig({
testDir: './e2e-react',
workers: 1,
timeout: 90000,
expect: {
timeout: 30000,
},
retries: 0,
reporter: 'list',
use: {
baseURL: 'http://127.0.0.1:4273',
viewport: { width: 1280, height: 720 },
colorScheme: 'dark',
locale: 'en-US',
timezoneId: 'UTC',
trace: 'retain-on-failure',
screenshot: 'only-on-failure',
video: 'retain-on-failure',
},
projects: [
{
name: 'chromium-react',
use: {
...devices['Desktop Chrome'],
launchOptions: {
args: ['--use-angle=swiftshader', '--use-gl=swiftshader'],
},
},
},
],
webServer: {
command: 'VITE_E2E=1 npm run dev:react -- --host 127.0.0.1 --port 4273',
url: 'http://127.0.0.1:4273/index.react.html',
reuseExistingServer: false,
timeout: 120000,
},
});

Some files were not shown because too many files have changed in this diff Show More