Phase B Step 8 (OpenTelemetry) and Phase C Step 9 (Wazero plugin runtime)
were structurally scaffolded but the tagged builds were placeholders that
errored at runtime. This commit lands the real implementations behind the
existing build tags, plus three review passes worth of fixes across the
plugin admin handler, plugin registry, telemetry adapter, and Solid client.
Telemetry (Phase B Step 8)
- Add real go.opentelemetry.io/otel{,/sdk,/exporters/{prometheus,otlp...}}
modules to go.mod plus contrib/instrumentation/net/http/otelhttp.
- Replace the telemetry_otel.go skeleton with a working Provider that
wires Prometheus + OTLP/gRPC exporters, otelhttp middleware, span and
meter adapters, and an idempotent Shutdown.
- AppMetrics cache is now reset *before* SetGlobal to close a race where
a concurrent NewAppMetrics() could observe a swapped provider but read
stale no-op instruments.
- Init releases the trace provider on a later prometheus exporter
failure so Init never leaks gRPC connections.
- convertAttrs handles int32/uint/uint32/uint64/float32 explicitly;
uint64 values that exceed math.MaxInt64 fall back to a STRING attr
rather than wrapping into a negative int64 and corrupting metrics.
- Tests under -tags otel cover the prometheus scrape, span lifecycle,
histogram recording, shutdown idempotency, AppMetrics rebind, and
the uint64 overflow fallback.
Plugin runtime (Phase C Step 9)
- Add github.com/tetratelabs/wazero v1.11.0 to go.mod.
- platformInit creates a shared wazero.Runtime with WASI preview1
pre-instantiated; activateWithRuntime compiles + instantiates each
plugin module under that runtime; platformDeactivate closes per-
plugin modules without tearing down the runtime.
- DisablePlugin now calls platformDeactivate so the wazero module is
freed immediately instead of leaking until registry Close.
- activate() captures runtimePlatform under r.mu.RLock and passes it as
a parameter to activateWithRuntime; the call no longer re-reads the
field, closing a race with concurrent Close.
- invokeCommand calls the plugin's command_dispatch export when
present; missing/broken exports return a user-facing diagnostic
instead of crashing the dispatcher.
- Tests under -tags wazero cover registry creation, module compilation,
re-enable after disable (verifies the leak fix), close-twice safety,
invalid wasm rejection, and DispatchCommand with a missing export.
Fixture is a 41-byte embedded add.wasm; no external asset required.
Plugin admin handler hardening
- /api/v1/admin/plugins/install now rejects uploads whose multipart
Content-Type is not application/zip|x-zip-compressed|octet-stream
(415) and uploads whose body lacks the PK\\x03\\x04 / PK\\x05\\x06
zip magic (400). The 16 MiB cap and registry-side zip-slip / symlink
/ size-bomb defences are still applied as before.
- New plugins_handler_test.go covers list-empty, install-503-when-nil,
content-type rejection, magic rejection, happy path, lifecycle 503,
invalid id, and isZipContentType / hasZipMagic helpers.
Solid client (Phase B Step 6) cleanup
- vitest.config.ts now wires vite-plugin-solid and broadens the test
glob to include src/**/*.test.tsx so Badge.test.tsx is actually
discovered (it was silently skipped).
- pluginBridge.ts targets postMessage at window.location.origin
instead of "*", and exposes a destroy() that detaches the message
listener and clears mounted frames.
- solidMount.ts imports the JSX type from "solid-js" instead of
"solid-js/web" (the latter does not re-export it), unblocking
npx tsc --noEmit.
Build/test status
- go build succeeds on default, -tags otel, -tags wazero, and
-tags otel,wazero.
- go test passes on every tag combination across telemetry, plugin,
api, ws, service, store, and the rest of the tree.
- Client: npx tsc --noEmit clean; vitest 3188/3188 across 112 files.
PHASE_BC_LOCAL_TODO.md is updated to mark the OTel modules + real Init,
the wazero module + real platformInit, and the test coverage that
landed in this commit as completed.
https://claude.ai/code/session_01AZni6CDSQeu67WSWY1YCDX
14 KiB
Phase B + C — Local Follow-up TODO
This file enumerates everything from phase-b-acceleration.md and
phase-c-differentiation.md that could not be completed inside the
sandboxed Claude session because the work requires:
- network access to fetch new modules / npm packages,
- a Go toolchain matching
go.mod'sgo 1.25.0directive, - a WASM toolchain (TinyGo / Rust / AssemblyScript),
- a real machine that can run
npm install,cargo,tauri, etc.
The session branch is claude/plan-phases-b-c-bGpoS. Everything below
must be run on a developer machine (or CI) before the branch is mergeable.
The session-resident plan that was actually executed lives in
/root/.claude/plans/woolly-wiggling-wolf.md (not in this repo).
Verification (do first — confirms the in-session work compiles)
cd Server && go build ./...— passes on the dev machine with Go 1.24.x.cd Server && go test ./store/... ./ws/... ./plugin/... ./telemetry/...— all pass; full suitego test ./...green.cd Server && go vet ./...— clean.cd Client/tauri-client && npm install && npm run lint && npm run build— Pulls insolid-js,vite-plugin-solid, and@solidjs/testing-library(added topackage.json); confirms the Solid pipeline compiles inside the existing Vite + TS setup.cd Client/tauri-client && npm run test— runs the newBadge.test.tsxsmoke test.
Phase B Step 6 — Solid.js migration (rest of the components)
The session landed:
- Vite + TS toolchain wiring (
vite.config.ts,tsconfig.json) solid-js+vite-plugin-solid+@solidjs/testing-libraryinpackage.jsonsrc/lib/solidAdapter.ts(wraps custom stores as Solid signals)src/lib/solidMount.ts({mount, destroy}adapter for Solid roots)src/components/solid/Badge.tsx— first leafsrc/components/solid/ChannelListItem.tsx— store-subscribed leafsrc/components/solid/Badge.test.tsx— pipeline smoke testsrc/components/solid/README.md— migration recipe
Still TODO locally:
- Run
npm installand verify the build passes (sandbox had no network). - Migrate the remaining leaf components in
src/components/one PR at a time, following the recipe insrc/components/solid/README.md. Suggested order: presence pills, typing indicators, message attachments, voice volume meters, then containers (channel list, member list, message list). - Once every leaf is migrated, replace the manual
mountSolidcalls in containers with native Solid components and delete the old vanilla DOM utilities (createComponent, factory shells) referenced fromsrc/components/. - Add a Vitest config preset under
vitest.config.tsthat pulls in@solidjs/testing-libraryautomatically (currently the test imports it directly).
Phase B Step 7 — Event persistence
The session landed:
Server/migrations/014_events_table.sql(SQLite)eventstable appended toServer/migrations/postgres/001_initial_schema.sqlServer/db/queries/sqlite/events.sql,Server/db/queries/postgres/events.sqlServer/db/persisted_event.go— domain typeEventStoresub-interface added toServer/store/store.go- SQLite implementation in
Server/store/sqlite_events.go(raw SQL via*sql.DB, nodbgendependency) - MemStore implementation in
Server/store/memstore_events.go - Postgres stubs returning
ErrPostgresNotImplemented Server/ws/event_persister.go— batched async writerServer/ws/event_pruner.go— retention pruner goroutine- Three
replayBuf.Pushcall sites inServer/ws/hub.gonow also callh.persistEvent(...) - Tiered reconnect replay in
Server/ws/serve.go(buffer → DB → full) - Reconnect-tier metrics in the hub + telemetry counter
EventPersistenceConfigadded toServer/config/config.gowith defaults{enabled: true, retention_hours: 24, batch_size: 50, batch_flush_ms: 100, pruner_interval_minutes: 60}Server/main.gowires the persister + prunerServer/ws/event_persister_test.go— batching, drop, drain tests
Still TODO locally:
- Run
make sqlc-generate— done;db/pgdbgen/events.sql.goanddb/pgdbgen/plugins.sql.gogenerated;//go:build postgrestag prepended to all 19 pgdbgen files to gate pgx/v5 import. - Replace the postgres EventStore stubs in
Server/store/postgres.gowith real implementations using PostgreSQL SQL syntax ($1/$2params,RETURNING id, nativebool/time.Time). - Add an integration test that pushes more than 1000 events through a
real hub with a 1000-slot buffer, disconnects at seq=500, and asserts
the DB tier returns the missing events. Landed in
Server/ws/reconnect_db_test.go(TestReconnect_BufferMiss_FallsBackToDBTier). - Add a
replay_sourcefield to the auth_ok payload — landed in Pass 4.buildAuthOKtakes the tier as a parameter, "none" on fresh connect, "buffer" or "db" on resume. - Document the new
event_persistenceblock indefaultYAMLinsideServer/config/config.go— landed in Pass 3.
Phase B Step 8 — OpenTelemetry
The session landed:
Server/telemetry/telemetry.go— public API + no-op providerServer/telemetry/telemetry_default.go— default-buildInitServer/telemetry/telemetry_otel.go— wazero/postgres-style build-tag skeleton (build with-tags otel); compiles only when the OTel modules are ingo.modand is currently a structural placeholderServer/telemetry/metrics.go—AppMetricsbundleServer/telemetry/middleware.go—HTTPMiddleware+PrometheusHandlerServer/telemetry/telemetry_test.goServer/api/router.gomountstelemetry.HTTPMiddleware()unconditionally and the Prometheus exporter when non-nilServer/main.gocallstelemetry.Initearly and defersShutdownTelemetryConfigadded toServer/config/config.go- Spans added to
MessageService.SendMessage,PermissionService.HasChannelPerm,ChannelService.ListVisibleChannels - Reconnect-tier counter wired into
WSReconnectTierTotalfromServer/ws/serve.go
Still TODO locally:
- Add the OTel modules to
go.mod— landed onclaude/review-phase-completion-PBExk. go.mod now carriesgo.opentelemetry.io/otel/sdk,.../exporters/prometheus,.../exporters/otlp/otlptrace/otlptracegrpc, andgo.opentelemetry.io/contrib/instrumentation/net/http/otelhttp. (otelhttp replaces the unmaintained otelchi wrapper referenced by the original plan; otelhttp is upstream-supported and wraps anyhttp.Handlerincluding a Chi router.) - Replace the placeholder body of
telemetry/telemetry_otel.go'sInitwith the real tracer + meter provider construction. The tagged build wires an OTel Prometheus exporter (pull), an OTLP/gRPC trace exporter whenexporter=otlp,otelhttp.NewHandleras the HTTP middleware, and a real provider that re-bindsAppMetricsinstruments viaresetAppMetricsForInit. Tests inServer/telemetry/telemetry_otel_test.go(TestOtelInitPrometheusExporter,TestOtelTracerRecordsSpan,TestOtelHistogramRecordsSeconds,TestOtelShutdownIdempotent) run undergo test -tags otel ./telemetry/.... - Add a CI job that exercises
go build -tags otel ./...andgo test -tags otel ./telemetry/.... Both pass locally against Go 1.25.1. - Add spans to the remaining service-layer entry points
(
DMService,VoiceService,InviteService,ModerationService,BlockService,UserService) — landed in Pass 3, one entrypoint per service. Add additional spans on demand. - Document the new
telemetryblock indefaultYAMLinsideServer/config/config.go— landed in Pass 3. - Add a
make otel-uptarget that spins up Jaeger via docker-compose for local tracing development. Landed inServer/Makefile(otel-up/otel-down); overlay file atServer/docker-compose.otel.yml; Prometheus config atServer/prometheus.dev.yml.
Phase C Step 9 — Wazero plugin runtime
The session landed:
Server/plugin/manifest.go— JSON manifest parser + capability checksServer/plugin/loader.go— directory scan + entrypoint validationServer/plugin/registry.go— registry + lifecycle (install/enable/uninstall)Server/plugin/host_commands.go,host_storage.go,host_events.go,host_http.go,host_ui.go— capability surfacesServer/plugin/sandbox_default.go— no-op runtime (default build)Server/plugin/sandbox_wazero.go—-tags wazeroskeletonServer/plugin/errors.goServer/plugin/plugin_test.goServer/plugin/examples/hello/plugin.json+README.mdServer/migrations/015_plugins.sql(SQLite)plugins+plugin_kvtables appended to the postgres schemaServer/db/queries/sqlite/plugins.sql,Server/db/queries/postgres/plugins.sqlPluginStoresub-interface inServer/store/store.gowith SQLite, MemStore, and postgres-stub implementationsServer/api/plugins_handler.go— admin REST surfaceServer/api/router.gomounts the admin plugin handlerServer/main.goconstructs and starts the registry whencfg.Plugins.EnabledPluginsConfigadded toServer/config/config.goClient/tauri-client/src/lib/pluginBridge.ts— iframe + postMessage hostClient/tauri-client/src/components/solid/PluginContainer.tsx— Solid host component for plugin tabs
Still TODO locally:
- Add wazero to
go.mod— landed onclaude/review-phase-completion-PBExk.go.modnow requiresgithub.com/tetratelabs/wazero v1.11.0. - Replace the placeholder body in
Server/plugin/sandbox_wazero.gowith real wazero runtime construction. The tagged build now owns a sharedwazero.Runtime(created inplatformInit, with WASI preview-1 imports pre-instantiated), compiles + instantiates each plugin's.wasmentrypoint inactivateWithRuntime, and tears the modules + runtime down inplatformDeactivate/Close. Tests inServer/plugin/sandbox_wazero_test.go(TestWazeroRegistryCreatesRuntime,TestWazeroActivateCompilesModule,TestWazeroDispatchCommandMissingExport,TestWazeroCloseTearsDownRuntime,TestWazeroInvalidWASMFailsActivation) run undergo test -tags wazero ./plugin/...using a 41-byte embedded WASM fixture — no external WASM asset required. - Replace JSON-only manifest parsing with TOML support behind the
wazerobuild tag (the design doc namesplugin.toml). Addgithub.com/BurntSushi/tomland aparseTOMLshim that falls back to the existingParseManifestif noplugin.tomlis found. - Wire
Server/plugin/host_events.gointo the WS pub/sub hub. Landed:EventSink.SetBroadcaster/Emitadded; hub gainsSetPluginEventSink;deliverBroadcastcallssink.Dispatchon each sequenced broadcast; wired inapi/router.go. - Wire
Server/plugin/host_commands.gointo the WS slash-command dispatcher. Landed:chat_commandV1 handler inServer/ws/handlers_command.go; hub gainsSetPluginRegistry; wired inapi/router.go. Tests inhandlers_command_test.go. - Pass the live
*plugin.RegistryfromServer/main.gointoNewPluginAdminHandler— landed in Pass 2. The router now accepts a*plugin.Registryparameter and the handler is also wrapped inadmin.RequireAdminAuth(Pass 2 closed the auth bypass too). - Add a precompiled trivial
.wasmblob underServer/plugin/examples/hello/hello.wasmso the example plugin can actually be loaded by an integration test once wazero is wired. Build it locally with TinyGo:cd Server/plugin/examples/hello tinygo build -o hello.wasm -target wasi ./main.go - Implement plugin marketplace install path
(
POST /api/v1/admin/plugins/installwith multipart zip) — landed in Pass 4.Registry.InstallFromZipdoes zip-slip validation, no symlinks, 16 MiB compressed cap, 64 MiB uncompressed cap, then atomic rename into the plugin directory. - Replace plugin postgres stubs in
Server/store/postgres.gowith real SQL implementations (same session as EventStore stubs). - Build the first real plugin: game detection. Pulls Steam API,
tracks playtime, exposes
/playtimeslash command. This is the acceptance criterion inphase-c-differentiation.md.
Build-tag matrix the user should set up in CI
| Tag set | What it builds | Why |
|---|---|---|
| (none) | Default sqlite-only server, no OTel SDK, no wazero | Existing path |
otel |
Above + OpenTelemetry SDK + Prometheus exporter | Phase B Step 8 |
wazero |
Above + plugin runtime executes WASM modules | Phase C Step 9 |
postgres |
Replaces sqlite with postgres backend | Phase A pending |
otel,wazero,postgres |
Full community-hub build | Production target |
Each tag is independently selectable; CI should test every combination at least minimally so the build-tag boundaries don't drift.
Things explicitly out of scope for this branch
(Documenting so reviewers don't expect them.)
- Migration of the entire vanilla TypeScript component tree to Solid. Two proof-of-concept components landed; the rest is mechanical PRs.
- Full OTel SDK wiring (only the public API + no-op default + structural build-tag skeleton landed).
- Real Wazero
.wasmexecution (only the registry, host APIs, and a build-tag skeleton landed). - Real game-detection plugin (the manifest fields and host APIs needed to build it are in place).
- A reverse postgres → sqlite migration (Phase A documented this is deliberately unavailable; nothing changed here).