From 8c2a6d7eaf0ac7abbf5a1a2f2c153f06cdcde888 Mon Sep 17 00:00:00 2001 From: aimerdoux Date: Tue, 2 Jun 2026 09:01:46 -0700 Subject: [PATCH] feat(wavex-ops): consolidate quota-gating, reengagement, realtime rejoin & managed-CTA Single consolidation of feat/skills-sh-listing (15 net commits) onto main. Drops the already-merged skills.sh scaffold (#31) and reconciles with the watchdog scaffold (#40) and Pool B work (#36/#37) already on main. Inference / quota safety: - G1 quota pre-flight gates the live agent handoff (claude-quota-preflight) - G2 quota circuit breaker pauses fleet on hard ceiling (ops script) - G1/G2 detect auth-failure, not just quota (the 91% gap) - inference worker: per-user Realtime channel auto-rejoin + onboarding URL-prefetch - Pool B per-request audit + provider fallback; null-safe pool-b-health cast wavex-os-server jobs/routes: - professional reengagement + booking-fulfillment resolver + inbound-quality sampler - mission-control + reengagement routes; operator-tunable agent model Connectors / data: - managed-Composio primary CTA in ConnectorsSidebar - cro.deals seed for open_deals routine; dev roadmap docs DB migrations (20260520*, 20260521*, 20260528*): user_event_stream, kpi_history, booking GMV rpc, fulfillment instrumentation, professional_reengagement, inbound_quality, attribution_coverage_view, reengagement_value_metrics (0528 collision resolved -> 000002). --- .gitignore | 2 + data/cro/deals.json | 378 +++++++++++++++ docs/ops/dev-roadmap-2026-05-28.md | 192 ++++++++ .../composio-shim/src/featured-toolkits.ts | 38 +- packages/composio-shim/src/types.ts | 4 + .../core/scripts/refresh-agent-economics.ts | 295 ++++++++++++ .../inference-server/src/realtime/worker.ts | 435 ++++++++++++++++-- .../src/ui/ConnectorsSidebar.tsx | 98 +++- packages/paperclip-plugin-wavex/src/worker.ts | 17 +- .../src/bridge/paperclip-handoff.ts | 11 +- packages/wavex-os-server/src/index.ts | 4 + .../src/jobs/booking-fulfillment-resolver.ts | 242 ++++++++++ .../src/jobs/professional-reengagement.ts | 300 ++++++++++++ .../src/lib/claude-quota-preflight.ts | 156 +++++++ .../inbound-quality-sampler.ts | 166 +++++++ .../src/mission-control/pool-b-health.ts | 12 +- .../wavex-os-server/src/routes/activate.ts | 45 +- .../wavex-os-server/src/routes/credentials.ts | 1 + .../src/routes/mission-control.ts | 69 +++ .../src/routes/reengagement.ts | 108 +++++ .../test/claude-quota-preflight.test.ts | 34 ++ .../wavex-os-server/test/reengagement.test.ts | 149 ++++++ scripts/ops/wavex-quota-circuit-breaker.mjs | 161 +++++++ ...60520000001_wavex_os_user_event_stream.sql | 254 ++++++++++ .../20260520000002_wavex_os_kpi_history.sql | 116 +++++ ...0003_wavex_os_snapshot_booking_gmv_rpc.sql | 32 ++ ...s_bookings_fulfillment_instrumentation.sql | 112 +++++ ...005_wavex_os_professional_reengagement.sql | 270 +++++++++++ ...0260521000002_wavex_os_inbound_quality.sql | 261 +++++++++++ ...001_wavex_os_attribution_coverage_view.sql | 37 ++ ...02_wavex_os_reengagement_value_metrics.sql | 144 ++++++ 31 files changed, 4073 insertions(+), 70 deletions(-) create mode 100644 data/cro/deals.json create mode 100644 docs/ops/dev-roadmap-2026-05-28.md create mode 100644 packages/core/scripts/refresh-agent-economics.ts create mode 100644 packages/wavex-os-server/src/jobs/booking-fulfillment-resolver.ts create mode 100644 packages/wavex-os-server/src/jobs/professional-reengagement.ts create mode 100644 packages/wavex-os-server/src/lib/claude-quota-preflight.ts create mode 100644 packages/wavex-os-server/src/mission-control/inbound-quality-sampler.ts create mode 100644 packages/wavex-os-server/src/routes/reengagement.ts create mode 100644 packages/wavex-os-server/test/claude-quota-preflight.test.ts create mode 100644 packages/wavex-os-server/test/reengagement.test.ts create mode 100755 scripts/ops/wavex-quota-circuit-breaker.mjs create mode 100644 supabase/migrations/20260520000001_wavex_os_user_event_stream.sql create mode 100644 supabase/migrations/20260520000002_wavex_os_kpi_history.sql create mode 100644 supabase/migrations/20260520000003_wavex_os_snapshot_booking_gmv_rpc.sql create mode 100644 supabase/migrations/20260520000004_wavex_os_bookings_fulfillment_instrumentation.sql create mode 100644 supabase/migrations/20260520000005_wavex_os_professional_reengagement.sql create mode 100644 supabase/migrations/20260521000002_wavex_os_inbound_quality.sql create mode 100644 supabase/migrations/20260528000001_wavex_os_attribution_coverage_view.sql create mode 100644 supabase/migrations/20260528000002_wavex_os_reengagement_value_metrics.sql diff --git a/.gitignore b/.gitignore index d92c23ac..510d1e8d 100644 --- a/.gitignore +++ b/.gitignore @@ -81,3 +81,5 @@ supabase/.temp/ # Operator-local QA spec fixtures (not shipped in customer installs) e2e/_qa-*.spec.ts + +demo-output/ diff --git a/data/cro/deals.json b/data/cro/deals.json new file mode 100644 index 00000000..511c54bf --- /dev/null +++ b/data/cro/deals.json @@ -0,0 +1,378 @@ +{ + "$schema_version": "1.0.0", + "table": "cro.deals", + "description": "WaveX partner-deal pipeline tracker. Source of truth for the `open_deals` snapshot produced by routine WAVAAAAA-17 (workflow:cro.demo:on_fire). One record per partner_id; multiple contacts at the same company use distinct ids (e.g. wolt-samuel, wolt-niilo).", + "stages": ["identified", "contacted", "demoed", "negotiating", "verbal", "won", "lost"], + "open_stages": ["identified", "contacted", "demoed", "negotiating", "verbal"], + "currency": "USD", + "gmv_basis_default": "design-partner pilot = $3000 (3 mo × $1k/mo per send-design-partner-day10.mjs offer). Identified pre-send rows carry $0 until first response.", + "updated_at": "2026-05-20T16:00:00Z", + "updated_by": "cro.close (agent b1dedd5b)", + "deals": [ + { + "partner_id": "onfleet", + "company": "Onfleet", + "contact": "Kjell", + "email": "kjell@onfleet.com", + "stage": "contacted", + "last_touch_at": "2026-05-19T13:00:00Z", + "last_touch_kind": "wave1-followup", + "expected_gmv": 3000, + "gmv_basis": "design-partner pilot ($1k/mo × 3 mo)", + "next_action": "Wait 5 business days for reply; if no response by 2026-05-26, move to lost.", + "next_action_owner": "cro.outbound", + "source": "tools/send-wave1-followup.mjs" + }, + { + "partner_id": "buildops", + "company": "BuildOps", + "contact": "Dzmitry", + "email": "dzmitry@buildops.com", + "stage": "contacted", + "last_touch_at": "2026-05-19T13:00:00Z", + "last_touch_kind": "wave1-followup", + "expected_gmv": 3000, + "gmv_basis": "design-partner pilot ($1k/mo × 3 mo)", + "next_action": "Wait 5 business days for reply; if no response by 2026-05-26, move to lost.", + "next_action_owner": "cro.outbound", + "source": "tools/send-wave1-followup.mjs" + }, + { + "partner_id": "current", + "company": "Current", + "contact": "Trevor", + "email": "trevor@current.com", + "stage": "contacted", + "last_touch_at": "2026-05-19T13:00:00Z", + "last_touch_kind": "wave1-followup", + "expected_gmv": 3000, + "gmv_basis": "design-partner pilot ($1k/mo × 3 mo)", + "next_action": "Wait 5 business days for reply; if no response by 2026-05-26, move to lost.", + "next_action_owner": "cro.outbound", + "source": "tools/send-wave1-followup.mjs" + }, + { + "partner_id": "slice", + "company": "Slice", + "contact": "David Billingham", + "email": "david.billingham@slice.com", + "stage": "contacted", + "last_touch_at": "2026-05-19T13:00:00Z", + "last_touch_kind": "wave1-resend-original-bounced", + "expected_gmv": 3000, + "gmv_basis": "design-partner pilot ($1k/mo × 3 mo)", + "next_action": "Verify deliverability; if no response by 2026-05-26, move to lost.", + "next_action_owner": "cro.outbound", + "source": "tools/send-wave1-followup.mjs" + }, + { + "partner_id": "wolt-samuel", + "company": "Wolt", + "contact": "Samuel Tervaskanto", + "email": "samuel.tervaskanto@wolt.com", + "stage": "contacted", + "last_touch_at": "2026-05-17T09:00:00Z", + "last_touch_kind": "design-partner-day0-cold", + "expected_gmv": 3000, + "gmv_basis": "design-partner pilot ($1k/mo × 3 mo)", + "next_action": "Send Day-7 follow-up on 2026-05-24T09:00Z (tools/send-design-partner-day7.mjs).", + "next_action_owner": "cro.outbound", + "source": "tools/send-design-partner-day7.mjs" + }, + { + "partner_id": "wolt-niilo", + "company": "Wolt", + "contact": "Niilo", + "email": "niilo@wolt.com", + "stage": "contacted", + "last_touch_at": "2026-05-17T09:00:00Z", + "last_touch_kind": "design-partner-day0-cold", + "expected_gmv": 3000, + "gmv_basis": "design-partner pilot ($1k/mo × 3 mo)", + "next_action": "Send Day-7 follow-up on 2026-05-24T09:00Z (tools/send-design-partner-day7.mjs).", + "next_action_owner": "cro.outbound", + "source": "tools/send-design-partner-day7.mjs" + }, + { + "partner_id": "discord", + "company": "Discord", + "contact": "Thomas Jacques", + "email": "thomas.jacques@discord.com", + "stage": "contacted", + "last_touch_at": "2026-05-17T09:00:00Z", + "last_touch_kind": "design-partner-day0-cold", + "expected_gmv": 3000, + "gmv_basis": "design-partner pilot ($1k/mo × 3 mo)", + "next_action": "Send Day-7 follow-up on 2026-05-24T09:00Z (tools/send-design-partner-day7.mjs).", + "next_action_owner": "cro.outbound", + "source": "tools/send-design-partner-day7.mjs" + }, + { + "partner_id": "robinhood", + "company": "Robinhood", + "contact": "Mayank Agarwal", + "email": "mayank.agarwal@robinhood.com", + "stage": "contacted", + "last_touch_at": "2026-05-17T09:00:00Z", + "last_touch_kind": "design-partner-day0-cold", + "expected_gmv": 3000, + "gmv_basis": "design-partner pilot ($1k/mo × 3 mo)", + "next_action": "Send Day-7 follow-up on 2026-05-24T09:00Z (tools/send-design-partner-day7.mjs).", + "next_action_owner": "cro.outbound", + "source": "tools/send-design-partner-day7.mjs" + }, + { + "partner_id": "n26", + "company": "N26", + "contact": "Gino Cordt", + "email": "gino.cordt@n26.com", + "stage": "contacted", + "last_touch_at": "2026-05-17T09:00:00Z", + "last_touch_kind": "design-partner-day0-cold", + "expected_gmv": 3000, + "gmv_basis": "design-partner pilot ($1k/mo × 3 mo)", + "next_action": "Send Day-7 follow-up on 2026-05-24T09:00Z (tools/send-design-partner-day7.mjs).", + "next_action_owner": "cro.outbound", + "source": "tools/send-design-partner-day7.mjs" + }, + { + "partner_id": "duolingo", + "company": "Duolingo", + "contact": "Natalie Glance", + "email": "natalie@duolingo.com", + "stage": "identified", + "last_touch_at": null, + "last_touch_kind": "address-resolved-after-bounces", + "expected_gmv": 0, + "gmv_basis": "no contact yet; first send pending in Day-7 batch", + "next_action": "First send goes out 2026-05-24 in Day-7 batch (nglance@ + natalie.glance@ both bounced; correct address resolved via WAVAAAA-446).", + "next_action_owner": "cro.outbound", + "source": "tools/send-design-partner-day7.mjs" + }, + { + "partner_id": "cerebral", + "company": "Cerebral", + "contact": "Sarah Griffis", + "email": "sarah.griffis@cerebral.com", + "stage": "identified", + "last_touch_at": null, + "last_touch_kind": "enriched-pre-send", + "expected_gmv": 0, + "gmv_basis": "gated on board approval 599e267e — no GMV until live send", + "next_action": "Live send via tools/send-wave2.mjs once gate 599e267e is lifted.", + "next_action_owner": "cro.outbound", + "source": "tools/send-wave2.mjs", + "blocked_by": "board gate 599e267e" + }, + { + "partner_id": "lemonade", + "company": "Lemonade", + "contact": "Tal Cohen", + "email": "tal.cohen@lemonade.com", + "stage": "identified", + "last_touch_at": null, + "last_touch_kind": "enriched-pre-send", + "expected_gmv": 0, + "gmv_basis": "gated on board approval 599e267e — no GMV until live send", + "next_action": "Live send via tools/send-wave2.mjs once gate 599e267e is lifted.", + "next_action_owner": "cro.outbound", + "source": "tools/send-wave2.mjs", + "blocked_by": "board gate 599e267e" + }, + { + "partner_id": "jobber", + "company": "Jobber", + "contact": "Ryan Jones", + "email": "ryan.jones@getjobber.com", + "stage": "identified", + "last_touch_at": null, + "last_touch_kind": "enriched-pre-send", + "expected_gmv": 0, + "gmv_basis": "gated on board approval 599e267e — no GMV until live send", + "next_action": "Live send via tools/send-wave2.mjs once gate 599e267e is lifted.", + "next_action_owner": "cro.outbound", + "source": "tools/send-wave2.mjs", + "blocked_by": "board gate 599e267e" + }, + { + "partner_id": "housecall-pro", + "company": "Housecall Pro", + "contact": "Manish Singh", + "email": "manish.singh@housecallpro.com", + "stage": "identified", + "last_touch_at": null, + "last_touch_kind": "enriched-pre-send", + "expected_gmv": 0, + "gmv_basis": "gated on board approval 599e267e — no GMV until live send", + "next_action": "Live send via tools/send-wave2.mjs once gate 599e267e is lifted.", + "next_action_owner": "cro.outbound", + "source": "tools/send-wave2.mjs", + "blocked_by": "board gate 599e267e" + }, + { + "partner_id": "bringg", + "company": "Bringg", + "contact": "Alexander Simenduev", + "email": "alexander.simenduev@bringg.com", + "stage": "identified", + "last_touch_at": null, + "last_touch_kind": "enriched-pre-send", + "expected_gmv": 0, + "gmv_basis": "gated on board approval 599e267e — no GMV until live send", + "next_action": "Live send via tools/send-wave2.mjs once gate 599e267e is lifted.", + "next_action_owner": "cro.outbound", + "source": "tools/send-wave2.mjs", + "blocked_by": "board gate 599e267e" + }, + { + "partner_id": "companycam", + "company": "CompanyCam", + "contact": "Keegan Rice", + "email": "keegan@companycam.com", + "stage": "identified", + "last_touch_at": null, + "last_touch_kind": "enriched-pre-send", + "expected_gmv": 0, + "gmv_basis": "gated on board approval 599e267e — no GMV until live send", + "next_action": "Live send via tools/send-wave3.mjs once gate 599e267e is lifted.", + "next_action_owner": "cro.outbound", + "source": "tools/send-wave3.mjs", + "blocked_by": "board gate 599e267e" + }, + { + "partner_id": "connecteam", + "company": "Connecteam", + "contact": "Daniel Nuriel", + "email": "daniel@connecteam.com", + "stage": "identified", + "last_touch_at": null, + "last_touch_kind": "enriched-pre-send", + "expected_gmv": 0, + "gmv_basis": "gated on board approval 599e267e — no GMV until live send", + "next_action": "Live send via tools/send-wave3.mjs once gate 599e267e is lifted.", + "next_action_owner": "cro.outbound", + "source": "tools/send-wave3.mjs", + "blocked_by": "board gate 599e267e" + }, + { + "partner_id": "zuper", + "company": "Zuper", + "contact": "Raghav Gurumani", + "email": "raghav@zuper.co", + "stage": "identified", + "last_touch_at": null, + "last_touch_kind": "enriched-pre-send", + "expected_gmv": 0, + "gmv_basis": "gated on board approval 599e267e — no GMV until live send", + "next_action": "Live send via tools/send-wave3.mjs once gate 599e267e is lifted.", + "next_action_owner": "cro.outbound", + "source": "tools/send-wave3.mjs", + "blocked_by": "board gate 599e267e" + }, + { + "partner_id": "raken", + "company": "Raken", + "contact": "Kevin McKee", + "email": "kevin.mckee@rakenapp.com", + "stage": "identified", + "last_touch_at": null, + "last_touch_kind": "enriched-pre-send", + "expected_gmv": 0, + "gmv_basis": "gated on board approval 599e267e — no GMV until live send", + "next_action": "Live send via tools/send-wave3.mjs once gate 599e267e is lifted.", + "next_action_owner": "cro.outbound", + "source": "tools/send-wave3.mjs", + "blocked_by": "board gate 599e267e" + }, + { + "partner_id": "fieldpulse", + "company": "FieldPulse", + "contact": "Gabriel Pinchev", + "email": "gabriel@fieldpulse.com", + "stage": "identified", + "last_touch_at": null, + "last_touch_kind": "enriched-pre-send", + "expected_gmv": 0, + "gmv_basis": "gated on board approval 599e267e — no GMV until live send", + "next_action": "Live send via tools/send-wave3.mjs once gate 599e267e is lifted.", + "next_action_owner": "cro.outbound", + "source": "tools/send-wave3.mjs", + "blocked_by": "board gate 599e267e" + }, + { + "partner_id": "breezeway", + "company": "Breezeway", + "contact": "Paul Northup", + "email": "paul.northup@breezeway.io", + "stage": "identified", + "last_touch_at": null, + "last_touch_kind": "enriched-pre-send", + "expected_gmv": 0, + "gmv_basis": "gated on board approval 599e267e — no GMV until live send", + "next_action": "Live send via tools/send-wave3.mjs once gate 599e267e is lifted.", + "next_action_owner": "cro.outbound", + "source": "tools/send-wave3.mjs", + "blocked_by": "board gate 599e267e" + }, + { + "partner_id": "greenlight", + "company": "Greenlight", + "contact": "Sameera Rao", + "email": "sameera.rao@greenlightcard.com", + "stage": "identified", + "last_touch_at": null, + "last_touch_kind": "enriched-pre-send", + "expected_gmv": 0, + "gmv_basis": "gated on board approval 599e267e — no GMV until live send", + "next_action": "Live send via tools/send-wave3.mjs once gate 599e267e is lifted.", + "next_action_owner": "cro.outbound", + "source": "tools/send-wave3.mjs", + "blocked_by": "board gate 599e267e" + }, + { + "partner_id": "step", + "company": "Step", + "contact": "Alexey K.", + "email": "alexey@step.com", + "stage": "identified", + "last_touch_at": null, + "last_touch_kind": "enriched-pre-send", + "expected_gmv": 0, + "gmv_basis": "gated on board approval 599e267e — no GMV until live send", + "next_action": "Live send via tools/send-wave3.mjs once gate 599e267e is lifted.", + "next_action_owner": "cro.outbound", + "source": "tools/send-wave3.mjs", + "blocked_by": "board gate 599e267e" + }, + { + "partner_id": "albert", + "company": "Albert", + "contact": "Rishi Patel", + "email": "rishi@albert.com", + "stage": "identified", + "last_touch_at": null, + "last_touch_kind": "enriched-pre-send", + "expected_gmv": 0, + "gmv_basis": "gated on board approval 599e267e — no GMV until live send", + "next_action": "Live send via tools/send-wave3.mjs once gate 599e267e is lifted.", + "next_action_owner": "cro.outbound", + "source": "tools/send-wave3.mjs", + "blocked_by": "board gate 599e267e" + }, + { + "partner_id": "brightline", + "company": "Brightline", + "contact": "Vicki Mar", + "email": "vmar@hellobrightline.com", + "stage": "identified", + "last_touch_at": null, + "last_touch_kind": "enriched-pre-send", + "expected_gmv": 0, + "gmv_basis": "gated on board approval 599e267e — no GMV until live send", + "next_action": "Live send via tools/send-wave3.mjs once gate 599e267e is lifted.", + "next_action_owner": "cro.outbound", + "source": "tools/send-wave3.mjs", + "blocked_by": "board gate 599e267e" + } + ] +} diff --git a/docs/ops/dev-roadmap-2026-05-28.md b/docs/ops/dev-roadmap-2026-05-28.md new file mode 100644 index 00000000..03b80aa4 --- /dev/null +++ b/docs/ops/dev-roadmap-2026-05-28.md @@ -0,0 +1,192 @@ +# WaveX OS — Dev Roadmap, 2026-05-28 + +Today's deliverables, ordered. Context: Codex landed a Pool B accountability +suite (mostly uncommitted) per `pool-b-production-gap-analysis-2026-05-27.md`. +The build is currently broken and the local fleet is dormant. This roadmap +sequences the work to get from "broken + dormant" → "Pool B accountable + +fleet live + can't burn quota invisibly." + +## State at start of day + +| Area | State | +|---|---| +| Build | ❌ BROKEN — 8 tsc errors in `wavex-os-server/src/mission-control/pool-b-health.ts` (null-safety, from PR #28) | +| inference-server | ✅ compiles clean (Codex's +825 worker / +464 os-paid changes are sound) | +| Local fleet | ⏸ dormant — Paperclip (3100) + mock-core (3101) not running | +| Inference mode | ✅ `~/.wavex-os/inference.env` = oauth (BYOC); agent model env-driven `WAVEX_AGENT_MODEL ?? claude-sonnet-4-6` | +| Codex Pool B suite | 🟡 uncommitted: audit table + RPCs + provider fallback + WAL + 8 migrations + gap doc | +| Plugin / connectors | ✅ wavex plugin installs (manifest v0.15.0, Connectors sidebar live); Composio still needs an API key | +| Claude Max quota | weekly 25% / sonnet bucket was the May-19 blocker → fixed by opus switch | + +## Uncommitted work inventory (Codex) + +**New source (untracked):** +- `inference-server/src/lib/{inference-audit,openai-responses,pool-b-control,pool-b-prompt-coverage}.ts` +- `wavex-os-server/src/jobs/{booking-fulfillment-resolver,professional-reengagement}.ts` +- `wavex-os-server/src/mission-control/inbound-quality-sampler.ts` +- `wavex-os-server/src/routes/reengagement.ts` + +**Modified (tracked):** `cloud-client/src/inference.ts`, `inference-server/src/{realtime/worker.ts,routes/admin.ts,routes/health.ts,routes/os-paid.ts}`, `paperclip-plugin-wavex/src/worker.ts`, `wavex-os-server/src/{bridge/paperclip-handoff.ts,index.ts,routes/mission-control.ts}` + +**8 untracked migrations** (May 20 + May 27): +- `20260520000001_wavex_os_user_event_stream` +- `20260520000002_wavex_os_kpi_history` +- `20260520000002_wavex_os_professional_reengagement` ⚠️ duplicate sequence prefix with kpi_history +- `20260520000003_wavex_os_snapshot_booking_gmv_rpc` +- `20260520000004_wavex_os_bookings_fulfillment_instrumentation` +- `20260521000002_wavex_os_inbound_quality` +- `20260527000001_wavex_os_device_lookup_rpc` ← Pool B rollout +- `20260527000002_wavex_os_pool_b_accountability` ← Pool B rollout + +--- + +## D1 — Unblock the build (P0, blocks everything) ~30 min + +Fix the 8 null-safety errors in `pool-b-health.ts` (lines 147, 183, 196, 230, +253, 308, 371, 375 — `wavex`/object possibly null). Then a full-workspace +typecheck must be green for non-vendor packages. + +**Done when:** `pnpm -r exec tsc --noEmit` shows 0 non-vendor errors. + +## D2 — Land Codex's Pool B accountability suite (P0) ~1.5h + +The gap-analysis doc is the spec. Review + typecheck + commit the uncommitted +suite in logically-grouped commits (don't dump 1300 lines in one): + +1. **migrations** — the 8 SQL files. **Resolve the duplicate `20260520000002` + prefix first** (rename professional_reengagement → `…000005`) or migrations + apply out of order. +2. **inference-server audit core** — `inference-audit.ts`, `pool-b-control.ts`, + `openai-responses.ts`, `pool-b-prompt-coverage.ts` + the `worker.ts` / + `os-paid.ts` / `admin.ts` wiring. +3. **wavex-os-server jobs + routes** — reengagement, booking-fulfillment, + inbound-quality + mission-control route additions. + +**Done when:** all committed, typecheck green, gap-doc's "What this PR changes" +section matches what's on disk. + +## D3 — Controlled Supabase rollout (P0, gated, follow gap-doc §rollout) ~30 min + +Per the gap doc — **do NOT blind `supabase db push`** (remote/local drift). +Apply ONLY the two May 27 Pool B migrations in a controlled release: +- `20260527000001_wavex_os_device_lookup_rpc` +- `20260527000002_wavex_os_pool_b_accountability` + +Then verify remotely that these RPCs exist: `wavex_os_device_lookup`, +`wavex_os_record_inference_audit`, `wavex_os_recent_inference_audit`, and that +`usage_ledger`'s pool check now includes `B`. + +> The May 20 migrations (event stream, kpi history, booking GMV snapshot, +> reengagement) are a separate batch — stage them after D3 once their feature +> code is committed. Don't bundle them into the Pool B safety release. + +**Done when:** the 3 RPCs resolve remotely + `usage_ledger` accepts pool=B. + +## D4 — Restart fleet on opus + verify health (P0) ~30 min + +Servers are down. Bring back `pnpm dev` (mock-core 3101 + onboarding-ui 5173), +confirm Paperclip 3100 is up, set `WAVEX_AGENT_MODEL=opus` in the fleet env +(Sonnet bucket is the one that throttles), resume the 35 agents **staggered** +(not all at once — that tripped the 429 burst on May 21). + +**Done when:** one CEO heartbeat completes (not failed) on opus, and ≥3 agents +have produced issue activity without `claude_transient_upstream`. + +## D5 — Pool B accountability smoke test (P1, gap-doc §rollout step 4) ~30 min + +With `WAVEX_OS_STREAMING_INFERENCE_ENABLED` unset, run the 4-case smoke: +1 HTTP prompt, 1 Realtime prompt, 1 rejected, 1 duplicate. Confirm all four +land in `inference_audit_events` and `/admin/pool-b/requests` returns them. + +**Done when:** all 4 cases queryable in Supabase within seconds. + +## D6 — Inference-handling gates G1 + G2 (P1, the "better inference handling" ask) ~1.5h + +These prevent the May-19 quota-storm from recurring: +- **G1** — pre-flight quota check before `activate` + before bulk pillar T2 + calls. If the target model bucket is exhausted, HALT with a clear operator + message + reset window instead of spawning 35 doomed agents. +- **G2** — heartbeat retry classifier: on `claude_transient_upstream` whose + message contains a usage-limit signature ("hit your … limit", "resets"), + switch to `paused_quota_exhausted` + schedule one retry after the reset + window — do NOT retry-storm into a hard ceiling. + +**Done when:** a simulated quota-exhausted response produces one paused run +with a reset-aware retry, not 3+ immediate retries. + +--- + +## Secondary (today if D1–D6 land early, else tomorrow) + +- **G3** — AllocationSlider reads live Claude quota state (per-model bucket %), + not just the operator's preference number. +- **G4** — surface `claude_transient_upstream` + quota state in Mission Control + banner + Telegram (hook the wavex-local-ops daemon). +- **Composio key UX** — the Directory modal asks for `ak_…` and says "stored in + `/.env`". Wire it so a key entered there persists + auto-loads on + restart, and the 16 toolkits flip `available → connectable`. +- **Phase 10 follow-up (#191)** — genericize the 9 mirror SKILL files so the + `applyManifestOverlay` strip-patterns can be deleted. + +## Sequencing rationale + +D1 first (nothing ships broken). D2 commits the in-flight value before it rots. +D3 is the gated DB release the gap doc insists on. D4 gets the fleet earning +again. D5 proves Pool B is accountable. D6 makes the whole thing safe to leave +running unattended — which is the actual product requirement behind "better +inference handling." + +## End-of-session status (2026-05-28, autonomous run) + +Branch `feat/skills-sh-listing`, pushed through `76ce0fb6`. + +| # | Status | Result | +|---|---|---| +| D1 | ✅ done | `e001d64c` — 8 null-safety errors fixed; build green (0 non-vendor tsc errors) | +| D2 | ✅ done | `cba4f681`+`3e76959b`+`0baa7a3f`+`3bb53968`+`19de4855` — Codex Pool B suite committed in 5 grouped commits; migration prefix collision resolved; all 4 touched packages typecheck clean | +| D3 | ✅ done | 2 May-27 migrations applied to prod Supabase; verified: `wavex_os_device_lookup`, `wavex_os_record_inference_audit`, `wavex_os_recent_inference_audit` all resolve; `usage_ledger` pool check now `A,B,C` | +| D6 | ✅ done | G1 `6c291153` (pre-flight quota gate on activate, classifier 4/4 unit-tested) + G2 `76ce0fb6` (circuit-breaker script). This is the "creation/activation" inference fix. | +| D4 | ⏸ DEFERRED | needs live servers + supervision — see runbook below | +| D5 | ⏸ DEFERRED | needs inference-server + a paired device — see runbook below | + +**Why D4/D5 were deferred (not skipped):** the entire point of today's work +was to stop invisible quota burn. Turning a 35-agent fleet loose unattended — +even on opus — before a human can watch it would contradict that. The fleet is +left dormant (safe default). G1 now gates any re-activation, and G2 can be +armed as a cron breaker the moment the fleet comes up. + +### D4 runbook (run supervised) + +```bash +cd /Users/geniex/wavex-os +export WAVEX_AGENT_MODEL=opus # Sonnet bucket throttles independently +pnpm dev # boots mock-core :3101 + onboarding-ui :5173 +# in another shell, confirm Paperclip :3100 is up (npx paperclipai run from ~/paperclip if not) +# the 35 agents already carry model=opus in Paperclip's DB (patched 2026-05-21) +# resume STAGGERED — do NOT mass-resume (that tripped the 429 burst): +# resume CEO first, watch one heartbeat complete, then resume the rest in small batches +curl -s -X POST http://127.0.0.1:3100/api/agents//heartbeat/invoke -d '{}' +# arm the breaker on a 10-min cron so a drain auto-pauses the fleet: +node scripts/ops/wavex-quota-circuit-breaker.mjs b515f8b2-0976-4838-b8a9-c08d430d8177 +``` + +### D5 runbook (Pool B 4-case smoke — gap-doc step 4) + +With `WAVEX_OS_STREAMING_INFERENCE_ENABLED` unset, drive: 1 HTTP prompt, +1 Realtime prompt, 1 rejected, 1 duplicate. Then confirm all four land: + +```sql +select route, status, request_id, occurred_at +from wavex_os.inference_audit_events +order by occurred_at desc limit 10; +``` +or `GET /admin/pool-b/requests` on the inference-server. + +## Hard constraints (carry-over) + +- NEVER clean `usage_ledger`, `inference_audit_events`, `fleet_digests`, + `injection_queue_v2`, `digest_access_log`, `optimizer_runs`, or heartbeat-run + history — they're training data for future Expert Agents. +- FROZEN paths per CLAUDE.md (`vendor/wavex-os/**`, `packages/healing/**`, + `packages/observability/src/**`, etc.) — surface concerns, don't edit. +- Controlled Supabase releases only — no blind `db push` (remote/local drift). diff --git a/packages/composio-shim/src/featured-toolkits.ts b/packages/composio-shim/src/featured-toolkits.ts index 29f01512..0ee38870 100644 --- a/packages/composio-shim/src/featured-toolkits.ts +++ b/packages/composio-shim/src/featured-toolkits.ts @@ -5,21 +5,27 @@ * and Phase 2 generation still produces a complete connector manifest. */ import type { FeaturedToolkit } from "./types.js"; +// Logos use Google's favicon service (full-color, visible on dark tiles, +// zero-maintenance). The UI's onError falls back to a monogram, so a +// missing icon degrades gracefully. Live Composio mode overrides these. +const ICON = (domain: string) => + `https://www.google.com/s2/favicons?domain=${domain}&sz=64`; + export const FEATURED_TOOLKITS: ReadonlyArray = [ - { slug: "slack", displayName: "Slack", category: "comms" }, - { slug: "telegram", displayName: "Telegram", category: "comms" }, - { slug: "discord", displayName: "Discord", category: "comms" }, - { slug: "gmail", displayName: "Gmail", category: "comms" }, - { slug: "outlook", displayName: "Outlook", category: "comms" }, - { slug: "hubspot", displayName: "HubSpot", category: "crm" }, - { slug: "salesforce", displayName: "Salesforce", category: "crm" }, - { slug: "stripe", displayName: "Stripe", category: "billing" }, - { slug: "mixpanel", displayName: "Mixpanel", category: "analytics" }, - { slug: "amplitude", displayName: "Amplitude", category: "analytics" }, - { slug: "github", displayName: "GitHub", category: "dev" }, - { slug: "linear", displayName: "Linear", category: "dev" }, - { slug: "notion", displayName: "Notion", category: "ops" }, - { slug: "google_calendar", displayName: "Google Calendar", category: "ops" }, - { slug: "microsoft_calendar", displayName: "Microsoft Calendar", category: "ops" }, - { slug: "google_drive", displayName: "Google Drive", category: "ops" }, + { slug: "slack", displayName: "Slack", category: "comms", logo: ICON("slack.com") }, + { slug: "telegram", displayName: "Telegram", category: "comms", logo: ICON("telegram.org") }, + { slug: "discord", displayName: "Discord", category: "comms", logo: ICON("discord.com") }, + { slug: "gmail", displayName: "Gmail", category: "comms", logo: ICON("gmail.com") }, + { slug: "outlook", displayName: "Outlook", category: "comms", logo: ICON("outlook.com") }, + { slug: "hubspot", displayName: "HubSpot", category: "crm", logo: ICON("hubspot.com") }, + { slug: "salesforce", displayName: "Salesforce", category: "crm", logo: ICON("salesforce.com") }, + { slug: "stripe", displayName: "Stripe", category: "billing", logo: ICON("stripe.com") }, + { slug: "mixpanel", displayName: "Mixpanel", category: "analytics", logo: ICON("mixpanel.com") }, + { slug: "amplitude", displayName: "Amplitude", category: "analytics", logo: ICON("amplitude.com") }, + { slug: "github", displayName: "GitHub", category: "dev", logo: ICON("github.com") }, + { slug: "linear", displayName: "Linear", category: "dev", logo: ICON("linear.app") }, + { slug: "notion", displayName: "Notion", category: "ops", logo: ICON("notion.so") }, + { slug: "google_calendar", displayName: "Google Calendar", category: "ops", logo: ICON("calendar.google.com") }, + { slug: "microsoft_calendar", displayName: "Microsoft Calendar", category: "ops", logo: ICON("outlook.com") }, + { slug: "google_drive", displayName: "Google Drive", category: "ops", logo: ICON("drive.google.com") }, ]; diff --git a/packages/composio-shim/src/types.ts b/packages/composio-shim/src/types.ts index 887fd27c..a88884b7 100644 --- a/packages/composio-shim/src/types.ts +++ b/packages/composio-shim/src/types.ts @@ -15,6 +15,10 @@ export interface FeaturedToolkit { slug: string; displayName: string; category: "comms" | "crm" | "billing" | "analytics" | "dev" | "ops" | "other"; + /** Brand logo URL for the curated/offline catalog. Live Composio mode + * supplies its own logos via toolkit.meta.logo; this is the fallback so + * the directory still shows real logos when the live catalog is down. */ + logo?: string; } export type ApiKeyValidation = diff --git a/packages/core/scripts/refresh-agent-economics.ts b/packages/core/scripts/refresh-agent-economics.ts new file mode 100644 index 00000000..8f9666e2 --- /dev/null +++ b/packages/core/scripts/refresh-agent-economics.ts @@ -0,0 +1,295 @@ +/** + * Standalone economics-refresh script (Option B — no server restart). + * + * Writes CURRENT_ECONOMICS.md to every agent's instructions dir for the given + * company. Mirrors the logic in paperclip/server/src/services/maintenance-service.ts + * (refreshAgentEconomicsFiles / computeFleetEconomics / renderAgentEconomicsMarkdown) + * but runs as a standalone read-only script against the embedded PostgreSQL that + * the live server is already using — no HTTP route, no server restart needed. + * + * Usage: + * tsx scripts/refresh-agent-economics.ts [--company ] + * + * Defaults to COMPANY_ID constant below when --company is omitted. + */ + +import { promises as fs } from "node:fs"; +import { homedir } from "node:os"; +import path from "node:path"; +import { createDb } from "../packages/db/src/index.js"; +import { loadConfig } from "../server/src/config.js"; + +// Default company — override via --company flag or PAPERCLIP_COMPANY_ID env var. +const DEFAULT_COMPANY_ID = "b515f8b2-0976-4838-b8a9-c08d430d8177"; + +// Token rates in cents per million tokens (mirrors maintenance-service.ts). +const TOKEN_RATES_CENTS_PER_MTOK: Record = { + "claude-opus-4-7": { input: 1500, cached: 150, output: 7500 }, + "claude-opus-4-7[1m]": { input: 1500, cached: 150, output: 7500 }, + "claude-opus-4-6": { input: 1500, cached: 150, output: 7500 }, + "claude-sonnet-4-6": { input: 300, cached: 30, output: 1500 }, + "claude-sonnet-4-5": { input: 300, cached: 30, output: 1500 }, + "claude-haiku-4-5-20251001": { input: 80, cached: 8, output: 400 }, + default: { input: 1500, cached: 150, output: 7500 }, +}; + +function tokenRateFor(model: string | null): { input: number; cached: number; output: number } { + if (!model) return TOKEN_RATES_CENTS_PER_MTOK.default; + return TOKEN_RATES_CENTS_PER_MTOK[model] ?? TOKEN_RATES_CENTS_PER_MTOK.default; +} + +type AgentEconomics = { + agentId: string; + name: string; + role: string | null; + model: string | null; + runs24h: number; + done24h: number; + comments24h: number; + outputTokens24h: number; + cachedTokens24h: number; + inputTokens24h: number; + burnCents24h: number; + fleetBurnCents24h: number; + fleetSharePct: number; + costPerDoneCents: number | null; + costPerCommentCents: number | null; + rank: number; + totalAgents: number; +}; + +type RawRow = { + agent_id: string; + name: string; + role: string | null; + model: string | null; + runs_24h: number; + done_24h: number; + comments_24h: number; + output_tokens_24h: number | null; + cached_tokens_24h: number | null; + input_tokens_24h: number | null; +}; + +async function computeFleetEconomics(db: ReturnType, companyId: string): Promise { + // Use the underlying postgres.js client for raw parameterised SQL — drizzle-orm + // is not a direct dep of this scripts package so we can't import its `sql` tag. + const pgSql = (db as unknown as { $client: (strings: TemplateStringsArray, ...values: unknown[]) => Promise }).$client; + const rows = await pgSql` + WITH agent_runs AS ( + SELECT agent_id, COUNT(*) AS runs FROM heartbeat_runs + WHERE company_id=${companyId} AND finished_at > NOW() - INTERVAL '24 hours' + GROUP BY agent_id + ), + agent_tokens AS ( + SELECT agent_id, + SUM(output_tokens) AS output_tokens, + SUM(cached_input_tokens) AS cached_tokens, + SUM(input_tokens) AS input_tokens + FROM cost_events + WHERE company_id=${companyId} AND occurred_at > NOW() - INTERVAL '24 hours' + GROUP BY agent_id + ), + agent_done AS ( + SELECT assignee_agent_id AS agent_id, COUNT(*) AS done + FROM issues + WHERE company_id=${companyId} AND completed_at > NOW() - INTERVAL '24 hours' AND status='done' + GROUP BY assignee_agent_id + ), + agent_comments AS ( + SELECT author_agent_id AS agent_id, COUNT(*) AS comments + FROM issue_comments + WHERE company_id=${companyId} AND created_at > NOW() - INTERVAL '24 hours' AND author_agent_id IS NOT NULL + GROUP BY author_agent_id + ) + SELECT + a.id AS agent_id, + a.name, + a.role, + a.adapter_config->>'model' AS model, + COALESCE(ar.runs, 0)::int AS runs_24h, + COALESCE(ad.done, 0)::int AS done_24h, + COALESCE(ac.comments, 0)::int AS comments_24h, + COALESCE(at.output_tokens, 0)::bigint AS output_tokens_24h, + COALESCE(at.cached_tokens, 0)::bigint AS cached_tokens_24h, + COALESCE(at.input_tokens, 0)::bigint AS input_tokens_24h + FROM agents a + LEFT JOIN agent_runs ar ON ar.agent_id=a.id + LEFT JOIN agent_tokens at ON at.agent_id=a.id + LEFT JOIN agent_done ad ON ad.agent_id=a.id + LEFT JOIN agent_comments ac ON ac.agent_id=a.id + WHERE a.company_id=${companyId} AND a.status NOT IN ('terminated') + `; + + const list = rows.map((r) => { + const rates = tokenRateFor(r.model); + const out = Number(r.output_tokens_24h ?? 0); + const cache = Number(r.cached_tokens_24h ?? 0); + const inp = Number(r.input_tokens_24h ?? 0); + const burnCents = (out * rates.output + cache * rates.cached + inp * rates.input) / 1_000_000; + return { + agentId: r.agent_id, + name: r.name, + role: r.role, + model: r.model, + runs24h: Number(r.runs_24h), + done24h: Number(r.done_24h), + comments24h: Number(r.comments_24h), + outputTokens24h: out, + cachedTokens24h: cache, + inputTokens24h: inp, + burnCents24h: Math.round(burnCents * 100) / 100, + fleetBurnCents24h: 0, + fleetSharePct: 0, + costPerDoneCents: r.done_24h ? Math.round((burnCents / Number(r.done_24h)) * 100) / 100 : null, + costPerCommentCents: r.comments_24h ? Math.round((burnCents / Number(r.comments_24h)) * 100) / 100 : null, + rank: 0, + totalAgents: 0, + }; + }); + + const fleetBurnCents = list.reduce((acc, a) => acc + a.burnCents24h, 0); + list.sort((a, b) => b.burnCents24h - a.burnCents24h); + list.forEach((a, i) => { + a.rank = i + 1; + a.totalAgents = list.length; + a.fleetBurnCents24h = Math.round(fleetBurnCents * 100) / 100; + a.fleetSharePct = fleetBurnCents > 0 ? Math.round((a.burnCents24h / fleetBurnCents) * 1000) / 10 : 0; + }); + return list; +} + +const usd = (cents: number): string => `$${(cents / 100).toFixed(2)}`; + +function renderAgentEconomicsMarkdown(econ: AgentEconomics): string { + const verbosity = + econ.outputTokens24h > 0 && econ.cachedTokens24h > 0 + ? (econ.outputTokens24h / econ.cachedTokens24h).toFixed(3) + : "n/a"; + const dollarsPerDone = econ.costPerDoneCents !== null ? usd(econ.costPerDoneCents) : "n/a (0 done)"; + const dollarsPerComment = econ.costPerCommentCents !== null ? usd(econ.costPerCommentCents) : "n/a (0 comments)"; + const flag = econ.fleetSharePct > 15 ? "⚠️ TOP BURNER" : econ.fleetSharePct > 8 ? "● heavy" : ""; + return `# CURRENT_ECONOMICS — ${econ.name} + +**Window:** rolling 24h. +**Computed:** ${new Date().toISOString()} +**Model:** \`${econ.model ?? "(default)"}\` + +## Your numbers + +| Metric | Value | +|---|---| +| Heartbeat runs | ${econ.runs24h} | +| Issues closed | ${econ.done24h} | +| Comments posted | ${econ.comments24h} | +| Output tokens | ${econ.outputTokens24h.toLocaleString()} | +| Cache tokens | ${econ.cachedTokens24h.toLocaleString()} | +| Input tokens | ${econ.inputTokens24h.toLocaleString()} | +| **Imputed burn** | **${usd(econ.burnCents24h)}** | +| **$ per closed issue** | **${dollarsPerDone}** | +| **$ per comment** | **${dollarsPerComment}** | +| Output:cache ratio (verbosity) | ${verbosity} | + +## Your share of the fleet + +- **Rank #${econ.rank} of ${econ.totalAgents}** by 24h burn ${flag} +- Your share: **${econ.fleetSharePct}%** of fleet (fleet total: ${usd(econ.fleetBurnCents24h)}) + +## Token-cost ladder (Anthropic API rates, model: ${econ.model ?? "default"}) + +For Opus 4.7: input \`$15/Mtok\` · cached \`$1.50/Mtok\` · output \`$75/Mtok\`. +**Output tokens cost 50× cache reads.** Verbosity is your single biggest cost lever. + +## Self-regulation rules (per SKILL_ECONOMIC_SELF_AWARENESS) + +1. **If your share > 15% OR $/done > $50:** this heartbeat MUST end with a delegate/kill/approve/escalate artifact, not a comment. (Same as SKILL_DELEGATE_OR_KILL.) +2. **If your output:cache ratio > 0.05** (you're producing fresh content faster than reusing cached): summarize aggressively, link to existing artifacts instead of restating, prefer 1-line decisions over multi-paragraph rationale. +3. **If you have 0 closed issues in 24h** with > 30 runs: you are spinning. Either close one this heartbeat or escalate the blocker to the board. +4. **Never restate ground-truth that's already in a comment thread.** Link to the prior comment by deep-link, don't recap. Each restatement costs your output tokens × 50. + +## What "good" looks like + +- Burn share < 5% AND ≥ 1 closed issue per day → you're producing leverage. +- Output:cache < 0.02 AND comments :: closures ratio < 5:1 → you're concise and decisive. +- Cost per done < $20 → you're efficient. +`; +} + +async function refreshAgentEconomicsFiles( + db: ReturnType, + companyId: string, +): Promise<{ written: number; failed: Array<{ agentId: string; name: string; error: string }>; fleetBurnCents: number }> { + const fleet = await computeFleetEconomics(db, companyId); + const failed: Array<{ agentId: string; name: string; error: string }> = []; + let written = 0; + for (const econ of fleet) { + const dir = path.join( + homedir(), + ".paperclip", + "instances", + "default", + "companies", + companyId, + "agents", + econ.agentId, + "instructions", + ); + try { + await fs.mkdir(dir, { recursive: true }); + await fs.writeFile(path.join(dir, "CURRENT_ECONOMICS.md"), renderAgentEconomicsMarkdown(econ), "utf8"); + written++; + } catch (err) { + failed.push({ + agentId: econ.agentId, + name: econ.name, + error: err instanceof Error ? err.message : String(err), + }); + } + } + return { written, failed, fleetBurnCents: fleet[0]?.fleetBurnCents24h ?? 0 }; +} + +function parseFlag(name: string): string | null { + const index = process.argv.indexOf(name); + if (index < 0) return null; + const value = process.argv[index + 1]; + return value && !value.startsWith("--") ? value : null; +} + +async function main() { + const config = loadConfig(); + const dbUrl = + process.env.DATABASE_URL?.trim() || + config.databaseUrl || + `postgres://paperclip:paperclip@127.0.0.1:${config.embeddedPostgresPort}/paperclip`; + + const companyId = + parseFlag("--company") || + process.env.PAPERCLIP_COMPANY_ID?.trim() || + DEFAULT_COMPANY_ID; + + const db = createDb(dbUrl); + + console.log(`Refreshing economics for company ${companyId} …`); + const result = await refreshAgentEconomicsFiles(db, companyId); + + if (result.failed.length > 0) { + console.error("Failed to write some files:"); + for (const f of result.failed) { + console.error(` ${f.agentId} (${f.name}): ${f.error}`); + } + } + + console.log( + `Written: ${result.written} Failed: ${result.failed.length} Fleet burn 24h: ${usd(result.fleetBurnCents)}`, + ); + + if (result.failed.length > 0) { + process.exit(1); + } +} + +main().catch((err) => { + console.error(err); + process.exit(1); +}); diff --git a/packages/inference-server/src/realtime/worker.ts b/packages/inference-server/src/realtime/worker.ts index 54c071f1..4193d7af 100644 --- a/packages/inference-server/src/realtime/worker.ts +++ b/packages/inference-server/src/realtime/worker.ts @@ -42,7 +42,8 @@ import { type AnthropicMessagesResponse, } from "../lib/anthropic-oauth.js"; import { runPoolBPromptCoverage } from "../lib/pool-b-prompt-coverage.js"; -import { incrementCounter, setIfAbsent } from "../lib/rate-limit.js"; +import { prefetchUrlsInPrompt } from "../lib/url-prefetch.js"; +import { incrementCounter, setIfAbsent, setAdd } from "../lib/rate-limit.js"; import { appendPoolBAuditEvent, isPoolBEnabled, @@ -85,6 +86,44 @@ const DEFAULT_MODEL = process.env.WAVEX_OS_INFERENCE_MODEL ?? "claude-sonnet-4-6 const MAX_OUTPUT_TOKENS_HARD = 8000; const ACTIVE_STATUSES = new Set(["trialing", "active", "past_due"]); +// ── Anonymous onboarding lane (Pool A) ────────────────────────────────── +// ONE shared channel (no user_id suffix, no device_jwt) for the pre-auth +// onboarding wizard. Customers publish here; we serve the call with the +// operator's rotating Claude Max OAuth (callAnthropicOAuth) and reply on +// `wavex-onboarding-response:`. Realtime gives us no caller IP, +// so the global hourly cap + the Pool A daily $ kill-switch are the abuse +// backstops. +const ONBOARDING_REQUEST_TOPIC = "wavex-onboarding-request"; +const ONBOARDING_RESPONSE_TOPIC_PREFIX = "wavex-onboarding-response:"; +const ONBOARDING_MODEL = process.env.WAVEX_ONBOARDING_MODEL ?? "claude-haiku-4-5-20251001"; +const ONBOARDING_MAX_OUTPUT_TOKENS = 1500; +const ONBOARDING_RATE_WINDOW_SEC = 3600; +const ONBOARDING_LIFETIME_WINDOW_SEC = 30 * 24 * 3600; // 30 days +const ONBOARDING_GLOBAL_RATE_MAX = parseInt(process.env.WAVEX_ONBOARDING_RATE_MAX ?? "400", 10); +// Per-install caps tuned so a full wizard run (~8-9 calls) plus retries fits. +const ONBOARDING_PER_INSTALL_HR_MAX = parseInt(process.env.WAVEX_ONBOARDING_PER_INSTALL_HR_MAX ?? "40", 10); +const ONBOARDING_PER_INSTALL_LIFETIME_MAX = parseInt(process.env.WAVEX_ONBOARDING_PER_INSTALL_LIFETIME_MAX ?? "150", 10); +const ONBOARDING_INSTALLS_PER_EMAIL_MAX = parseInt(process.env.WAVEX_ONBOARDING_INSTALLS_PER_EMAIL_MAX ?? "5", 10); +const POOL_A_DAILY_CAP_CENTS = parseInt(process.env.POOL_A_DAILY_CAP_CENTS ?? "1000", 10); + +// ── Connector lane (Composio OAuth + manifest, from the published domain) ─ +// A thin, allowlisted HTTP-over-Realtime proxy to the local wavex-os-server +// (:3101). The published SPA can't reach the Mac directly, so it publishes on +// the shared `wavex-connector-request` channel; we relay the call to the +// server's connector routes (initiate OAuth, list connections, generate +// manifest) and reply on `wavex-connector-response:`. Path is +// allowlisted so this can't be used to hit arbitrary :3101 routes. +const CONNECTOR_REQUEST_TOPIC = "wavex-connector-request"; +const CONNECTOR_RESPONSE_TOPIC_PREFIX = "wavex-connector-response:"; +const CONNECTOR_SERVER_URL = (process.env.WAVEX_CONNECTOR_SERVER_URL ?? "http://127.0.0.1:3101").replace(/\/+$/, ""); +const CONNECTOR_ALLOWED_PREFIXES = [ + "/wavex-os/onboarding/connectors", + "/wavex-os/onboarding/connector-manifest", + "/wavex-os/onboarding/connector-recommendations", +]; +const CONNECTOR_RATE_WINDOW_SEC = 3600; +const CONNECTOR_GLOBAL_RATE_MAX = parseInt(process.env.WAVEX_CONNECTOR_RATE_MAX ?? "600", 10); + type Logger = { info: (msg: Record | string, ...args: unknown[]) => void; warn: (msg: Record | string, ...args: unknown[]) => void; @@ -1284,6 +1323,273 @@ async function handleAnthropicRequest(args: { } } +interface OnboardingRequestPayload { + request_id?: unknown; + prompt?: unknown; + max_output_tokens?: unknown; + purpose?: unknown; + email?: unknown; + install_id?: unknown; + company_id?: unknown; + model?: unknown; +} + +/** Pool A — anonymous onboarding inference over Realtime. + * + * The onboarding wizard runs before sign-in and before device pairing, so + * there is no device_jwt and no per-user channel. Customers publish on the + * shared ONBOARDING_REQUEST_TOPIC; we serve the call with the operator's + * rotating Claude Max OAuth (Pool A) and reply on + * `wavex-onboarding-response:`. + * + * Guards: purpose must be `onboarding:*`; a global hourly request cap; an + * optional per-company cap; the Pool A daily spend kill-switch; an output + * token clamp. Everything degrades to a structured { ok:false } so the + * wizard never blocks. */ +async function handleOnboardingRequest(args: { + supabase: SupabaseClient; + payload: OnboardingRequestPayload; + log: Logger; +}): Promise { + const { supabase, payload, log } = args; + const started = Date.now(); + + const request_id = typeof payload.request_id === "string" ? payload.request_id : null; + const prompt = typeof payload.prompt === "string" ? payload.prompt : null; + const purpose = typeof payload.purpose === "string" ? payload.purpose : ""; + const email = typeof payload.email === "string" ? payload.email.trim().toLowerCase() : null; + const installId = + typeof payload.install_id === "string" + ? payload.install_id + : typeof payload.company_id === "string" + ? payload.company_id + : null; + const model = typeof payload.model === "string" ? payload.model : ONBOARDING_MODEL; + const maxOut = Math.min( + typeof payload.max_output_tokens === "number" && payload.max_output_tokens > 0 + ? payload.max_output_tokens + : 1024, + ONBOARDING_MAX_OUTPUT_TOKENS, + ); + + if (!request_id || !prompt) { + log.warn({ hasReqId: !!request_id }, "ignoring malformed onboarding request"); + return; + } + + const respond = async (body: Record): Promise => { + try { + const ch = supabase.channel(`${ONBOARDING_RESPONSE_TOPIC_PREFIX}${request_id}`); + await new Promise((resolve) => { + ch.subscribe((status) => { + if (status === "SUBSCRIBED" || status === "CHANNEL_ERROR" || status === "TIMED_OUT") resolve(); + }); + setTimeout(resolve, 5_000); + }); + await ch.send({ type: "broadcast", event: BROADCAST_EVENT, payload: body }); + await supabase.removeChannel(ch); + } catch (e) { + log.error({ err: (e as Error).message, request_id }, "onboarding response broadcast failed"); + } + }; + + // Purpose gate — this lane exists only for the onboarding wizard. + if (!purpose.startsWith("onboarding:")) { + await respond({ request_id, ok: false, error: "purpose_not_allowed" }); + return; + } + + // Global hourly request cap (Realtime gives us no client IP; this plus the + // daily $ cap below are the abuse backstops). + const globalCount = await incrementCounter("onb:rate:global", ONBOARDING_RATE_WINDOW_SEC); + if (globalCount > ONBOARDING_GLOBAL_RATE_MAX) { + await respond({ request_id, ok: false, error: "rate_limited", message: "onboarding inference is busy; try again shortly" }); + return; + } + // Per-install caps (soft UX guard; the global + daily $ caps are the hard + // backstops since Realtime can't see the caller IP and install_id/email are + // client-provided). + if (installId) { + const hr = await incrementCounter(`onb:install:${installId}:hr`, ONBOARDING_RATE_WINDOW_SEC); + const life = await incrementCounter(`onb:install:${installId}:life`, ONBOARDING_LIFETIME_WINDOW_SEC); + if (hr > ONBOARDING_PER_INSTALL_HR_MAX || life > ONBOARDING_PER_INSTALL_LIFETIME_MAX) { + await respond({ request_id, ok: false, error: "rate_limited", message: "onboarding inference limit reached for this session" }); + return; + } + } + // Distinct installs per email — blunts a single email spinning up endless + // fresh installs to dodge the per-install caps. + if (email) { + const { size } = await setAdd(`onb:email-installs:${email}`, installId ?? "unknown", ONBOARDING_LIFETIME_WINDOW_SEC); + if (size > ONBOARDING_INSTALLS_PER_EMAIL_MAX) { + await respond({ request_id, ok: false, error: "rate_limited", message: "too many onboarding sessions for this email" }); + return; + } + } + + // Pool A daily spend kill-switch. Fail-open on lookup error — a metrics + // hiccup shouldn't block onboarding. + try { + const { data: burn } = await supabase.rpc("wavex_os_pool_a_burn_today"); + if (typeof burn === "number" && burn >= POOL_A_DAILY_CAP_CENTS) { + await respond({ request_id, ok: false, error: "cap_hit", message: "daily onboarding inference cap reached" }); + return; + } + } catch { + /* fail open */ + } + + try { + // URL-prefetch: when the prompt contains a URL (e.g. the operator pasted + // their site at Pillar 1), fetch the page content and inject it as + // grounding so the model infers from the real site, not just the domain + // string. Idempotent + no-ops when there's no URL. Best-effort: a prefetch + // failure must not block inference, so fall back to the raw prompt. + let groundedPrompt = prompt; + try { + groundedPrompt = (await prefetchUrlsInPrompt(prompt)).prompt; + } catch (pfErr) { + log.warn({ request_id, err: (pfErr as Error).message }, "url prefetch failed; using raw prompt"); + } + const resp = await callAnthropicOAuthRaw({ + model, + max_tokens: maxOut, + messages: [{ role: "user", content: groundedPrompt }], + }); + const content = (resp.content ?? []) + .map((c) => ((c as { type?: string; text?: string }).type === "text" ? (c as { text?: string }).text ?? "" : "")) + .join(""); + const usage = resp.usage ?? { input_tokens: 0, output_tokens: 0 }; + const costCents = Math.round(calcCostUsd(resp.model ?? model, usage) * 100); + + await respond({ + request_id, + ok: true, + content, + model: resp.model ?? model, + usage: { + input_tokens: usage.input_tokens, + output_tokens: usage.output_tokens, + duration_ms: Date.now() - started, + }, + }); + + // Fire-and-forget Pool A ledger row (anonymous onboarding spend). No + // subscription/device/IP; install_id carries the company slug when present. + void supabase + .rpc("wavex_os_record_usage", { + p_pool: "A", + p_subscription_id: null, + p_request_id: request_id, + p_model: resp.model ?? model, + p_prompt_tokens: usage.input_tokens, + p_completion_tokens: usage.output_tokens, + p_cache_read_tokens: usage.cache_read_input_tokens ?? 0, + p_cache_creation_tokens: usage.cache_creation_input_tokens ?? 0, + p_cost_cents: costCents, + p_status: "ok", + p_device_id: null, + p_error_class: null, + p_install_id: installId ?? "onboarding-anon", + p_email: email, + p_ip_24: null, + p_deliverable_id: null, + p_agent_id: null, + }) + .then((r: { error: unknown }) => { + if (r.error) log.error({ err: r.error, request_id }, "onboarding usage_ledger insert failed"); + }); + + log.info( + { request_id, purpose, model: resp.model ?? model, out_tokens: usage.output_tokens, duration_ms: Date.now() - started }, + "onboarding inference ok", + ); + } catch (e) { + const err = e as { status?: number; message?: string }; + log.error({ request_id, status: err.status, err: err.message }, "onboarding inference failed"); + await respond({ request_id, ok: false, error: "upstream_error", message: err.message ?? "inference failed" }); + } +} + +interface ConnectorRequestPayload { + request_id?: unknown; + method?: unknown; + path?: unknown; + body?: unknown; +} + +/** Connector lane — allowlisted HTTP proxy to the local wavex-os-server. + * + * Lets the published SPA (wavexcard.com), which can't reach the Mac + * directly, drive the phase-2 connector flow (Composio OAuth + manifest) + * over Supabase Realtime. We relay `{method, path, body}` to + * CONNECTOR_SERVER_URL only if `path` matches the connector allowlist, then + * reply with the server's JSON on `wavex-connector-response:`. + * + * Degrades to { ok:false } on any failure; the vendored UI surfaces the + * ApiError. Path allowlist + global rate cap are the guards (no IP over + * Realtime). */ +async function handleConnectorRequest(args: { + supabase: SupabaseClient; + payload: ConnectorRequestPayload; + log: Logger; +}): Promise { + const { supabase, payload, log } = args; + const request_id = typeof payload.request_id === "string" ? payload.request_id : null; + const method = payload.method === "POST" ? "POST" : "GET"; + const path = typeof payload.path === "string" ? payload.path : null; + + if (!request_id || !path) { + log.warn({ hasReqId: !!request_id }, "ignoring malformed connector request"); + return; + } + + const respond = async (body: Record): Promise => { + try { + const ch = supabase.channel(`${CONNECTOR_RESPONSE_TOPIC_PREFIX}${request_id}`); + await new Promise((resolve) => { + ch.subscribe((status) => { + if (status === "SUBSCRIBED" || status === "CHANNEL_ERROR" || status === "TIMED_OUT") resolve(); + }); + setTimeout(resolve, 5_000); + }); + await ch.send({ type: "broadcast", event: BROADCAST_EVENT, payload: body }); + await supabase.removeChannel(ch); + } catch (e) { + log.error({ err: (e as Error).message, request_id }, "connector response broadcast failed"); + } + }; + + // Allowlist: only connector routes, no path traversal. + const pathname = path.split("?")[0] ?? ""; + const allowed = !path.includes("..") && CONNECTOR_ALLOWED_PREFIXES.some((p) => pathname.startsWith(p)); + if (!allowed) { + await respond({ request_id, ok: false, error: "path_not_allowed" }); + return; + } + + const globalCount = await incrementCounter("conn:rate:global", CONNECTOR_RATE_WINDOW_SEC); + if (globalCount > CONNECTOR_GLOBAL_RATE_MAX) { + await respond({ request_id, ok: false, error: "rate_limited" }); + return; + } + + try { + const init: RequestInit = { method, headers: { "Content-Type": "application/json" } }; + if (method === "POST") init.body = JSON.stringify(payload.body ?? {}); + const resp = await fetch(`${CONNECTOR_SERVER_URL}${path}`, init); + const text = await resp.text(); + let data: unknown; + try { data = JSON.parse(text); } catch { data = text; } + await respond({ request_id, ok: resp.ok, status: resp.status, data }); + log.info({ request_id, method, path: pathname, status: resp.status }, "connector proxy ok"); + } catch (e) { + const err = e as { message?: string }; + log.error({ request_id, path: pathname, err: err.message }, "connector proxy failed"); + await respond({ request_id, ok: false, error: "upstream_error", message: err.message ?? "proxy failed" }); + } +} + export interface RealtimeWorkerDeps { log: Logger; supabaseUrl?: string; @@ -1315,50 +1621,106 @@ export async function startRealtimeWorker(deps: RealtimeWorkerDeps): Promise(); const anthropicChannelsByUserId = new Map(); - const subscribeUser = async (userId: string): Promise => { - if (!channelsByUserId.has(userId)) { - const topic = `${REQUEST_TOPIC_PREFIX}${userId}`; + // Shared anonymous lanes (onboarding Pool A + connector proxy). No user_id, + // no device_jwt — the wizard runs before pairing/sign-in. + // + // CRITICAL RESILIENCE: Supabase Realtime channels drop on network blips / + // token refresh (CHANNEL_ERROR / TIMED_OUT / CLOSED). Without re-subscribing + // the lane goes SILENTLY DEAD — requests time out and onboarding shows blank + // inference (no preselection) until the process restarts. So we auto-rejoin + // on error with a short backoff. (Field incident 2026-06-02: the onboarding + // + connector channels errored and stayed dead for ~hour → blank Pillar 1.) + const sharedLanes: Array<{ topic: string; ch: RealtimeChannel | null }> = []; + const subscribeShared = ( + topic: string, + label: string, + onPayload: (payload: Record) => void, + ): void => { + const lane: { topic: string; ch: RealtimeChannel | null } = { topic, ch: null }; + sharedLanes.push(lane); + const join = (): void => { const ch = supabase .channel(topic) - .on("broadcast", { event: BROADCAST_EVENT }, (msg: { payload?: RequestPayload }) => { - void handleRequest({ - supabase, - topicUserId: userId, - payload: msg.payload ?? {}, - log, - }); + .on("broadcast", { event: BROADCAST_EVENT }, (msg: { payload?: Record }) => { + onPayload(msg.payload ?? {}); }) .subscribe((status) => { if (status === "SUBSCRIBED") { - log.info({ topic, user_id: userId }, "realtime worker subscribed"); + log.info({ topic }, `${label} subscribed`); } else if (status === "CHANNEL_ERROR" || status === "TIMED_OUT" || status === "CLOSED") { - log.warn({ topic, status }, "realtime channel status"); + log.warn({ topic, status }, `${label} channel ${status} — rejoining in 3s`); + const stale = lane.ch; + setTimeout(() => { + if (stale) void supabase.removeChannel(stale).catch(() => {}); + join(); + }, 3000); } }); - channelsByUserId.set(userId, ch); + lane.ch = ch; + }; + join(); + }; + + subscribeShared(ONBOARDING_REQUEST_TOPIC, "onboarding lane", (payload) => { + void handleOnboardingRequest({ supabase, payload: payload as OnboardingRequestPayload, log }); + }); + subscribeShared(CONNECTOR_REQUEST_TOPIC, "connector lane", (payload) => { + void handleConnectorRequest({ supabase, payload: payload as ConnectorRequestPayload, log }); + }); + + const subscribeUser = async (userId: string): Promise => { + // Same auto-rejoin resilience as the shared lanes: a dropped per-user + // channel (CHANNEL_ERROR / TIMED_OUT / CLOSED) that never re-subscribes + // leaves a paired customer's Pool B inference silently dead until restart. + if (!channelsByUserId.has(userId)) { + const topic = `${REQUEST_TOPIC_PREFIX}${userId}`; + const joinReq = (): void => { + const ch = supabase + .channel(topic) + .on("broadcast", { event: BROADCAST_EVENT }, (msg: { payload?: RequestPayload }) => { + void handleRequest({ supabase, topicUserId: userId, payload: msg.payload ?? {}, log }); + }) + .subscribe((status) => { + if (status === "SUBSCRIBED") { + log.info({ topic, user_id: userId }, "realtime worker subscribed"); + } else if (status === "CHANNEL_ERROR" || status === "TIMED_OUT" || status === "CLOSED") { + log.warn({ topic, status }, "realtime channel status — rejoining in 3s"); + const stale = channelsByUserId.get(userId); + setTimeout(() => { + if (stale) void supabase.removeChannel(stale).catch(() => {}); + joinReq(); + }, 3000); + } + }); + channelsByUserId.set(userId, ch); + }; + joinReq(); } // Second channel: anthropic-messages variant for the Claude Code proxy. if (!anthropicChannelsByUserId.has(userId)) { const topic = `${ANTHROPIC_REQUEST_TOPIC_PREFIX}${userId}`; - const ch = supabase - .channel(topic) - .on("broadcast", { event: ANTHROPIC_BROADCAST_EVENT }, (msg: { payload?: AnthropicRequestPayload }) => { - void handleAnthropicRequest({ - supabase, - topicUserId: userId, - payload: msg.payload ?? {}, - log, + const joinAnthropic = (): void => { + const ch = supabase + .channel(topic) + .on("broadcast", { event: ANTHROPIC_BROADCAST_EVENT }, (msg: { payload?: AnthropicRequestPayload }) => { + void handleAnthropicRequest({ supabase, topicUserId: userId, payload: msg.payload ?? {}, log }); + }) + .subscribe((status) => { + if (status === "SUBSCRIBED") { + log.info({ topic, user_id: userId }, "anthropic-messages worker subscribed"); + } else if (status === "CHANNEL_ERROR" || status === "TIMED_OUT" || status === "CLOSED") { + log.warn({ topic, status }, "anthropic-messages channel status — rejoining in 3s"); + const stale = anthropicChannelsByUserId.get(userId); + setTimeout(() => { + if (stale) void supabase.removeChannel(stale).catch(() => {}); + joinAnthropic(); + }, 3000); + } }); - }) - .subscribe((status) => { - if (status === "SUBSCRIBED") { - log.info({ topic, user_id: userId }, "anthropic-messages worker subscribed"); - } else if (status === "CHANNEL_ERROR" || status === "TIMED_OUT" || status === "CLOSED") { - log.warn({ topic, status }, "anthropic-messages channel status"); - } - }); - anthropicChannelsByUserId.set(userId, ch); + anthropicChannelsByUserId.set(userId, ch); + }; + joinAnthropic(); } }; @@ -1405,12 +1767,23 @@ export async function startRealtimeWorker(deps: RealtimeWorkerDeps): Promise [ ...Array.from(channelsByUserId.keys()).map((u) => `${REQUEST_TOPIC_PREFIX}${u}`), ...Array.from(anthropicChannelsByUserId.keys()).map((u) => `${ANTHROPIC_REQUEST_TOPIC_PREFIX}${u}`), + ONBOARDING_REQUEST_TOPIC, + CONNECTOR_REQUEST_TOPIC, ], }; } diff --git a/packages/paperclip-plugin-wavex/src/ui/ConnectorsSidebar.tsx b/packages/paperclip-plugin-wavex/src/ui/ConnectorsSidebar.tsx index e01ff8c6..ff2e6a9e 100644 --- a/packages/paperclip-plugin-wavex/src/ui/ConnectorsSidebar.tsx +++ b/packages/paperclip-plugin-wavex/src/ui/ConnectorsSidebar.tsx @@ -609,20 +609,28 @@ function DirectoryModal({ Checking Composio… ) : (!setupStatus.data?.valid && !setupStatus.data?.managed) ? ( - // SetupScreen only renders when there's something the *user* - // can do — paste a key. In WaveX-managed mode (operator runs - // WaveX-as-a-service, customer never holds the Composio key) - // we always fall through to the catalog, even if `valid: false`. - // An empty/degraded catalog is the right signal — "Composio - // is temporarily unavailable, your subscription covers it - // when it's back" — not "go enter your own key here". - { - void setupStatus.refresh(); - void catalog.refresh(); - }} - /> + // Not ready (neither a valid key nor WaveX-managed). Two very + // different reasons land here and must NOT be conflated: + // 1. Backend unreachable (worker couldn't reach :3101 → the + // handler returns `ok: false`). This is transient infra, + // never the customer's problem. Show "temporarily + // unavailable" — asking them for a Composio key here is + // exactly wrong, especially in a managed deployment. + // 2. Genuine self-serve setup (OSS user, no key yet). Only + // then is the key-entry SetupScreen the right surface. + // (In WaveX-managed mode `managed: true` skips both — it falls + // through to the catalog above, even when `valid: false`.) + setupStatus.data && setupStatus.data.ok === false ? ( + void setupStatus.refresh()} /> + ) : ( + { + void setupStatus.refresh(); + void catalog.refresh(); + }} + /> + ) ) : ( <> {/* Search row */} @@ -1301,6 +1309,68 @@ function SetupScreen({ ); } +// Shown when the connectors backend (:3101) is unreachable. This is a +// transient infra state, not a setup task — so it never asks for a key. +// In a WaveX-managed deployment the operator owns the Composio auth, so +// the only customer-facing action is "retry". +function UnavailablePanel({ onRetry }: { onRetry: () => void }) { + return ( +
+
+
+ +
+

+ Connectors are temporarily unavailable +

+

+ The connector service is reconnecting. Your WaveX subscription + covers connectors — nothing to set up on your end. Try again in a + moment. +

+ +
+
+ ); +} + function EmptyState({ onClear }: { onClear: () => void }) { return (
{ + const cfg = (await ctx.config.get()) as PluginConfig | null; + const base = cfg?.wavexApiBase ?? DEFAULT_WAVEX_BASE; + const id = await resolveWavexSlug(String(companyId ?? "")); + if (!id) return { ok: false, perSourceRows: 0, perChannelRows: 0 }; + const r = await localFetch( + `${base}/api/mission-control/${encodeURIComponent(id)}/sample-inbound-quality`, + { method: "POST", headers: { "Content-Type": "application/json" }, body: "{}" }, + ); + if (!r.ok) return { ok: false, perSourceRows: 0, perChannelRows: 0, status: r.status }; + return await r.json(); + }); + ctx.data.register("mission-control-scoreboard", async ({ companyId }) => { const cfg = (await ctx.config.get()) as PluginConfig | null; const base = cfg?.wavexApiBase ?? DEFAULT_WAVEX_BASE; diff --git a/packages/wavex-os-server/src/bridge/paperclip-handoff.ts b/packages/wavex-os-server/src/bridge/paperclip-handoff.ts index 6a88437f..c0dc727e 100644 --- a/packages/wavex-os-server/src/bridge/paperclip-handoff.ts +++ b/packages/wavex-os-server/src/bridge/paperclip-handoff.ts @@ -863,7 +863,16 @@ async function hireOne( // the repo-versioned default is the safe fallback for every fresh fleet. command: process.env.PAPERCLIP_HANDOFF_WRAPPER ?? join(resolveRepoRoot(), "scripts", "ops", "claude-keychain-wrapper.sh"), - model: "claude-sonnet-4-6", + // Model is operator-tunable via WAVEX_AGENT_MODEL. Default stays + // sonnet for cost, BUT the operator's Max plan tracks per-model + // sub-limits (5h / weekly / "Sonnet only") independently. When the + // Sonnet bucket is exhausted while "all models" weekly still has + // headroom, every agent heartbeat fails with "You've hit your + // Sonnet limit" even though overall quota is fine (observed + // 2026-05-19 dogfood deploy — 50 runs failed on a Sonnet ceiling + // at 25% weekly). Setting WAVEX_AGENT_MODEL=opus (or any model with + // remaining quota) unblocks the fleet without waiting for reset. + model: process.env.WAVEX_AGENT_MODEL ?? "claude-sonnet-4-6", dangerouslySkipPermissions: true, timeoutSec: 600, graceSec: 30, diff --git a/packages/wavex-os-server/src/index.ts b/packages/wavex-os-server/src/index.ts index 721bb634..f12ca6a0 100644 --- a/packages/wavex-os-server/src/index.ts +++ b/packages/wavex-os-server/src/index.ts @@ -57,6 +57,8 @@ import { registerReferralRoutes } from "./routes/referrals.js"; import { registerGitHubReposRoute } from "./routes/github-repos.js"; import { registerMissionControlRoutes } from "./routes/mission-control.js"; import { startReferralEmailBScheduler } from "./jobs/referral-email-b.js"; +import { startProfessionalReengagementScheduler } from "./jobs/professional-reengagement.js"; +import { registerReengagementRoutes } from "./routes/reengagement.js"; let bootstrapped = false; function bootstrap(): void { @@ -116,7 +118,9 @@ export function registerWavexOsRoutes(app: FastifyInstance): void { registerReferralRoutes(app); registerGitHubReposRoute(app); registerMissionControlRoutes(app); + registerReengagementRoutes(app); startReferralEmailBScheduler(); + startProfessionalReengagementScheduler(); } export { applyStateBridge, getInstanceDir, getOnboardingDir, getWavexDataRoot } from "./state-bridge.js"; diff --git a/packages/wavex-os-server/src/jobs/booking-fulfillment-resolver.ts b/packages/wavex-os-server/src/jobs/booking-fulfillment-resolver.ts new file mode 100644 index 00000000..e6d4dfd9 --- /dev/null +++ b/packages/wavex-os-server/src/jobs/booking-fulfillment-resolver.ts @@ -0,0 +1,242 @@ +/** Booking fulfillment resolver — timer fallback (WAVAAAAA-218) + * + * Runs hourly. For each booking where + * fulfillment_status = 'pending' + * AND paid_at IS NOT NULL + * AND now() > booking_time + experience_duration + interval '24 hours' + * it transitions the row to: + * fulfillment_status = 'fulfilled' + * fulfilled_at = now() + * fulfillment_source = 'timer_fallback' + * + * Idempotent: the WHERE clause filters out anything non-pending, so a + * replay after partner_api or user_confirm has already closed the row + * is a no-op. The priority order (partner_api > user_confirm > + * timer_fallback > operator_manual) is enforced by ordering of + * resolvers: this fallback only fires after 24h, by which point higher- + * priority resolvers have had ample time. + * + * Telemetry: emits Mixpanel `booking_fulfillment_resolved` with + * { booking_id, source: 'timer_fallback', booking_time, + * experience_duration_seconds, resolved_at }. + * CDO / TELEMETRY owns the event schema — sibling registration issue. + * + * Required env vars: + * SUPABASE_URL — PostgREST endpoint + * SUPABASE_SERVICE_ROLE_KEY — service-role JWT + * Optional env vars: + * MIXPANEL_PROJECT_TOKEN — if absent, telemetry is skipped + * WAVEX_FULFILLMENT_RESOLVER_DRY_RUN — "true" | "false" (default "false") + * WAVEX_FULFILLMENT_RESOLVER_BATCH — integer (default 500) + */ + +interface SupabaseConfig { + url: string; + key: string; +} + +interface PendingBooking { + id: string; + booking_time: string; // ISO timestamp + experience_duration_seconds: number; + paid_at: string; +} + +function supabaseConfig(): SupabaseConfig | null { + const url = process.env.SUPABASE_URL; + const key = process.env.SUPABASE_SERVICE_ROLE_KEY; + if (!url || !key) return null; + return { url, key }; +} + +function isDryRun(): boolean { + const raw = (process.env.WAVEX_FULFILLMENT_RESOLVER_DRY_RUN ?? "false").toLowerCase(); + return raw === "true"; +} + +function envInt(name: string, fallback: number): number { + const raw = process.env[name]; + if (!raw) return fallback; + const n = Number.parseInt(raw, 10); + return Number.isFinite(n) && n > 0 ? n : fallback; +} + +async function selectExpiredPending( + cfg: SupabaseConfig, + limit: number, +): Promise { + // RPC lives in wavex_os schema (added in this issue's sibling migration + // wavex_os_list_expired_pending_bookings — defensively inlined here as + // a PostgREST query against public.bookings until that RPC lands). + const params = new URLSearchParams({ + select: "id,booking_time,experience_duration_seconds,paid_at", + fulfillment_status: "eq.pending", + paid_at: "not.is.null", + limit: String(limit), + }); + const res = await fetch(`${cfg.url}/rest/v1/bookings?${params.toString()}`, { + method: "GET", + headers: { + apikey: cfg.key, + Authorization: `Bearer ${cfg.key}`, + }, + }); + if (!res.ok) { + const detail = await res.text().catch(() => ""); + throw new Error(`select pending bookings failed: ${res.status} ${detail}`); + } + const rows = (await res.json().catch(() => [])) as PendingBooking[]; + + // Filter the "now() > booking_time + duration + 24h" clause client-side + // because PostgREST cannot express the additive comparison against a + // computed column directly. The partial index keeps the row count + // bounded; the filter is O(returned rows). + const cutoff = Date.now(); + return rows.filter((r) => { + const bt = new Date(r.booking_time).getTime(); + if (!Number.isFinite(bt)) return false; + const expiry = bt + (r.experience_duration_seconds * 1000) + (24 * 3600 * 1000); + return cutoff > expiry; + }); +} + +async function markFulfilledTimer( + cfg: SupabaseConfig, + bookingId: string, +): Promise { + // Conditional update: WHERE fulfillment_status = 'pending' keeps this + // idempotent even under concurrent resolvers (partner_api / user_confirm) + // closing the same row. + const params = new URLSearchParams({ + id: `eq.${bookingId}`, + fulfillment_status: "eq.pending", + }); + const res = await fetch(`${cfg.url}/rest/v1/bookings?${params.toString()}`, { + method: "PATCH", + headers: { + "Content-Type": "application/json", + apikey: cfg.key, + Authorization: `Bearer ${cfg.key}`, + Prefer: "return=representation", + }, + body: JSON.stringify({ + fulfillment_status: "fulfilled", + fulfilled_at: new Date().toISOString(), + fulfillment_source: "timer_fallback", + }), + }); + if (!res.ok) { + const detail = await res.text().catch(() => ""); + console.error( + `[booking-fulfillment-resolver] update failed booking=${bookingId}: ${res.status} ${detail}`, + ); + return false; + } + const rows = (await res.json().catch(() => [])) as unknown[]; + return Array.isArray(rows) && rows.length > 0; +} + +async function emitMixpanel( + bookingId: string, + bookingTime: string, + durationSeconds: number, +): Promise { + const token = process.env.MIXPANEL_PROJECT_TOKEN; + if (!token) return; + const event = { + event: "booking_fulfillment_resolved", + properties: { + token, + distinct_id: bookingId, + booking_id: bookingId, + source: "timer_fallback", + booking_time: bookingTime, + experience_duration_seconds: durationSeconds, + resolved_at: new Date().toISOString(), + }, + }; + const payload = Buffer.from(JSON.stringify(event)).toString("base64"); + const ctrl = new AbortController(); + const t = setTimeout(() => ctrl.abort(), 5_000); + try { + const res = await fetch( + `https://api.mixpanel.com/track?data=${encodeURIComponent(payload)}`, + { method: "GET", signal: ctrl.signal }, + ); + if (!res.ok) { + console.error( + `[booking-fulfillment-resolver] mixpanel emit failed booking=${bookingId}: ${res.status}`, + ); + } + } catch (e) { + console.error( + `[booking-fulfillment-resolver] mixpanel emit threw booking=${bookingId}: ${ + e instanceof Error ? e.message : String(e) + }`, + ); + } finally { + clearTimeout(t); + } +} + +export interface FulfillmentResolverRunResult { + checked: number; + resolved: number; + dryRun: number; + errors: number; +} + +export async function runBookingFulfillmentResolverJob(): Promise { + const cfg = supabaseConfig(); + if (!cfg) { + console.warn( + "[booking-fulfillment-resolver] Supabase not configured — skipping run", + ); + return { checked: 0, resolved: 0, dryRun: 0, errors: 0 }; + } + const batch = envInt("WAVEX_FULFILLMENT_RESOLVER_BATCH", 500); + const dry = isDryRun(); + + let candidates: PendingBooking[]; + try { + candidates = await selectExpiredPending(cfg, batch); + } catch (e) { + console.error( + `[booking-fulfillment-resolver] candidate select threw: ${ + e instanceof Error ? e.message : String(e) + }`, + ); + return { checked: 0, resolved: 0, dryRun: 0, errors: 1 }; + } + + let resolved = 0; + let errors = 0; + for (const row of candidates) { + if (dry) continue; + try { + const updated = await markFulfilledTimer(cfg, row.id); + if (updated) { + resolved += 1; + await emitMixpanel(row.id, row.booking_time, row.experience_duration_seconds); + } + } catch (e) { + errors += 1; + console.error( + `[booking-fulfillment-resolver] resolve threw booking=${row.id}: ${ + e instanceof Error ? e.message : String(e) + }`, + ); + } + } + + const result: FulfillmentResolverRunResult = { + checked: candidates.length, + resolved, + dryRun: dry ? candidates.length : 0, + errors, + }; + console.info( + `[booking-fulfillment-resolver] run complete: ${JSON.stringify(result)}`, + ); + return result; +} diff --git a/packages/wavex-os-server/src/jobs/professional-reengagement.ts b/packages/wavex-os-server/src/jobs/professional-reengagement.ts new file mode 100644 index 00000000..14803cd3 --- /dev/null +++ b/packages/wavex-os-server/src/jobs/professional-reengagement.ts @@ -0,0 +1,300 @@ +/** Professional re-engagement Telegram nudge (WAVAAAAA-63) + * + * Runs hourly. Calls Supabase RPC wavex_os_list_professionals_with_stale_booking + * to find professionals (users with >=1 booking_confirmed event) whose last + * booking_confirmed was >7 days ago and who haven't been nudged inside the + * cadence cooldown. For each candidate it composes a concierge-voice + * Telegram message and sends via the bot, then logs the nudge so the + * re-activation metric (booking_confirmed within 48h) can be computed. + * + * Dry-run gating: + * WAVEX_REENGAGEMENT_DRY_RUN — default "true". Must be set to "false" + * (case-insensitive) to actually send Telegram messages. While true, + * the job still selects candidates and writes nudge rows with + * dry_run=true so the cadence and cohort sizing can be validated + * without pinging real users. + * + * Message copy: + * Default copy lives in DEFAULT_NUDGE_TEMPLATE below. CRO / EXPANSION + * can override it via WAVEX_REENGAGEMENT_MESSAGE without a deploy. + * The template supports {{days}} substitution (days since last booking). + * + * Required env vars: + * SUPABASE_URL — PostgREST endpoint + * SUPABASE_SERVICE_ROLE_KEY — service-role JWT + * TELEGRAM_BOT_TOKEN — concierge bot token (for real sends) + * Optional env vars: + * WAVEX_REENGAGEMENT_DRY_RUN — "true" | "false" (default "true") + * WAVEX_REENGAGEMENT_GAP_DAYS — integer (default 7) + * WAVEX_REENGAGEMENT_COOLDOWN_HOURS — integer (default 168 = 7d) + * WAVEX_REENGAGEMENT_BATCH_LIMIT — integer (default 100) + * WAVEX_REENGAGEMENT_MESSAGE — string with optional {{days}} + */ + +interface SupabaseConfig { + url: string; + key: string; +} + +interface StaleProfessional { + user_id: string; + telegram_user_id: string; + chat_id: string; + last_booking_at: string; // ISO timestamp + last_nudged_at: string | null; // ISO timestamp +} + +const DEFAULT_NUDGE_TEMPLATE = + "Ready to book your next dinner? It's been {{days}} days since your last one — " + + "want me to line something up?"; + +function supabaseConfig(): SupabaseConfig | null { + const url = process.env.SUPABASE_URL; + const key = process.env.SUPABASE_SERVICE_ROLE_KEY; + if (!url || !key) return null; + return { url, key }; +} + +function isDryRun(): boolean { + const raw = (process.env.WAVEX_REENGAGEMENT_DRY_RUN ?? "true").toLowerCase(); + return raw !== "false"; +} + +function envInt(name: string, fallback: number): number { + const raw = process.env[name]; + if (!raw) return fallback; + const n = Number.parseInt(raw, 10); + return Number.isFinite(n) && n > 0 ? n : fallback; +} + +function renderMessage(lastBookingAt: string): string { + const template = process.env.WAVEX_REENGAGEMENT_MESSAGE ?? DEFAULT_NUDGE_TEMPLATE; + const ms = Date.now() - new Date(lastBookingAt).getTime(); + const days = Math.max(1, Math.floor(ms / 86_400_000)); + return template.replace(/{{days}}/g, String(days)); +} + +async function listCandidates( + cfg: SupabaseConfig, + gapDays: number, + cooldownHours: number, + limit: number, +): Promise { + const res = await fetch( + `${cfg.url}/rest/v1/rpc/wavex_os_list_professionals_with_stale_booking`, + { + method: "POST", + headers: { + "Content-Type": "application/json", + apikey: cfg.key, + Authorization: `Bearer ${cfg.key}`, + }, + body: JSON.stringify({ p_gap_days: gapDays, p_cooldown_hours: cooldownHours }), + }, + ); + if (!res.ok) { + const detail = await res.text().catch(() => ""); + throw new Error( + `wavex_os_list_professionals_with_stale_booking failed: ${res.status} ${detail}`, + ); + } + const rows = (await res.json().catch(() => [])) as StaleProfessional[]; + return rows.slice(0, limit); +} + +async function recordNudge( + cfg: SupabaseConfig, + args: { + user_id: string; + telegram_user_id: string; + chat_id: string; + last_booking_at: string; + message_text: string; + message_id: string | null; + dry_run: boolean; + }, +): Promise { + const res = await fetch( + `${cfg.url}/rest/v1/rpc/wavex_os_record_professional_reengagement_nudge`, + { + method: "POST", + headers: { + "Content-Type": "application/json", + apikey: cfg.key, + Authorization: `Bearer ${cfg.key}`, + }, + body: JSON.stringify({ + p_user_id: args.user_id, + p_telegram_user_id: args.telegram_user_id, + p_chat_id: args.chat_id, + p_last_booking_at: args.last_booking_at, + p_message_text: args.message_text, + p_message_id: args.message_id, + p_dry_run: args.dry_run, + }), + }, + ); + if (!res.ok) { + const detail = await res.text().catch(() => ""); + console.error( + `[professional-reengagement] failed to record nudge for user=${args.user_id}: ${res.status} ${detail}`, + ); + } +} + +interface TelegramSendResult { + ok: boolean; + message_id?: string; + error?: string; +} + +async function sendTelegram( + token: string, + chatId: string, + text: string, +): Promise { + const ctrl = new AbortController(); + const t = setTimeout(() => ctrl.abort(), 10_000); + try { + const res = await fetch( + `https://api.telegram.org/bot${encodeURIComponent(token)}/sendMessage`, + { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ chat_id: chatId, text }), + signal: ctrl.signal, + }, + ); + if (!res.ok) { + const detail = await res.text().catch(() => ""); + return { ok: false, error: `${res.status} ${detail}` }; + } + const body = (await res.json().catch(() => null)) as + | { ok: boolean; result?: { message_id?: number } } + | null; + const mid = body?.result?.message_id; + return { ok: true, message_id: mid !== undefined ? String(mid) : undefined }; + } catch (e) { + return { ok: false, error: e instanceof Error ? e.message : String(e) }; + } finally { + clearTimeout(t); + } +} + +export interface ReengagementRunResult { + checked: number; + sent: number; + dryRun: number; + skipped: number; + errors: number; +} + +export async function runProfessionalReengagementJob(): Promise { + const cfg = supabaseConfig(); + if (!cfg) { + console.warn( + "[professional-reengagement] Supabase not configured — skipping run", + ); + return { checked: 0, sent: 0, dryRun: 0, skipped: 0, errors: 0 }; + } + + const dryRun = isDryRun(); + const gapDays = envInt("WAVEX_REENGAGEMENT_GAP_DAYS", 7); + const cooldownHours = envInt("WAVEX_REENGAGEMENT_COOLDOWN_HOURS", 168); + const limit = envInt("WAVEX_REENGAGEMENT_BATCH_LIMIT", 100); + const token = process.env.TELEGRAM_BOT_TOKEN; + + let candidates: StaleProfessional[]; + try { + candidates = await listCandidates(cfg, gapDays, cooldownHours, limit); + } catch (err) { + console.error("[professional-reengagement] candidate query failed:", err); + return { checked: 0, sent: 0, dryRun: 0, skipped: 0, errors: 1 }; + } + + let sent = 0; + let dryRunRecorded = 0; + let skipped = 0; + let errors = 0; + + for (const c of candidates) { + const text = renderMessage(c.last_booking_at); + + let send: TelegramSendResult = { ok: true }; + if (dryRun) { + console.log( + `[professional-reengagement] [dry-run] user=${c.user_id} chat=${c.chat_id} text=${JSON.stringify(text)}`, + ); + dryRunRecorded++; + } else if (!token) { + console.warn( + "[professional-reengagement] TELEGRAM_BOT_TOKEN not set — cannot send; recording as skipped", + ); + skipped++; + continue; + } else { + send = await sendTelegram(token, c.chat_id, text); + if (!send.ok) { + console.error( + `[professional-reengagement] send failed user=${c.user_id}: ${send.error}`, + ); + errors++; + continue; + } + sent++; + } + + await recordNudge(cfg, { + user_id: c.user_id, + telegram_user_id: c.telegram_user_id, + chat_id: c.chat_id, + last_booking_at: c.last_booking_at, + message_text: text, + message_id: send.message_id ?? null, + dry_run: dryRun, + }); + } + + console.log( + `[professional-reengagement] checked=${candidates.length} sent=${sent} dryRun=${dryRunRecorded} skipped=${skipped} errors=${errors}`, + ); + return { + checked: candidates.length, + sent, + dryRun: dryRunRecorded, + skipped, + errors, + }; +} + +// ─── scheduler ───────────────────────────────────────────────────────────── + +const HOUR_MS = 60 * 60 * 1000; +let schedulerHandle: ReturnType | null = null; + +export function startProfessionalReengagementScheduler(): void { + if (schedulerHandle) return; + + void runProfessionalReengagementJob().catch((err) => + console.error("[professional-reengagement] initial run failed:", err), + ); + + schedulerHandle = setInterval(() => { + void runProfessionalReengagementJob().catch((err) => + console.error("[professional-reengagement] scheduled run failed:", err), + ); + }, HOUR_MS); + + schedulerHandle.unref?.(); + + console.log( + `[professional-reengagement] hourly scheduler started (dryRun=${isDryRun()})`, + ); +} + +export function stopProfessionalReengagementScheduler(): void { + if (schedulerHandle) { + clearInterval(schedulerHandle); + schedulerHandle = null; + } +} diff --git a/packages/wavex-os-server/src/lib/claude-quota-preflight.ts b/packages/wavex-os-server/src/lib/claude-quota-preflight.ts new file mode 100644 index 00000000..8934c1b1 --- /dev/null +++ b/packages/wavex-os-server/src/lib/claude-quota-preflight.ts @@ -0,0 +1,156 @@ +/** Claude quota pre-flight (G1). + * + * The operator's Claude Max plan tracks per-model sub-limits (5h / + * weekly / "Sonnet only") independently. When the bucket for the model + * the fleet uses is exhausted, EVERY agent heartbeat fails with + * "You've hit your limit · resets " — even when the + * overall weekly still has headroom. On 2026-05-19 this spawned 35 + * agents that produced 50 doomed runs before anyone noticed. + * + * This module makes a cheap canary call to the configured agent model + * before a bulk spawn (activate handoff) so we can refuse to turn loose + * a fleet that will only burn quota on failures. The signal that + * matters only surfaces on a real call — `claude auth status` reports + * loggedIn:true even when the model bucket is dry — so the probe issues + * a 1-token request and inspects the response. + * + * Distinguishes these states: + * - "ok" — model responded; safe to spawn + * - "exhausted" — hard usage-limit hit ("hit your … limit / resets") + * - "auth_failed" — login/org-access failure ("organization does not + * have access … please login again"). This was the + * DOMINANT failure on 2026-05-27 (174/192 = 91%), + * model-agnostic (failed runs were already on opus), + * so switching models does NOT fix it — re-auth does. + * HALTs the spawn like "exhausted". + * - "transient" — server-side 429 ("temporarily limiting"); not a + * quota ceiling, callers may proceed (heartbeat + * retry/backoff handles it) + * - "unknown" — probe failed for an unrelated reason (claude not + * installed, timeout); callers decide (default: + * proceed, don't block the wizard on a flaky probe) + */ +import { spawn } from "node:child_process"; +import { join } from "node:path"; +import { fileURLToPath } from "node:url"; + +export type QuotaState = "ok" | "exhausted" | "auth_failed" | "transient" | "unknown"; + +export interface QuotaProbeResult { + state: QuotaState; + model: string; + /** Human-readable reason, surfaced to the operator. */ + detail: string; + /** Parsed reset hint from the limit message, e.g. "May 24 at 6am". */ + resetHint: string | null; +} + +const PROBE_TIMEOUT_MS = 25_000; + +function repoRoot(): string { + // src lives at packages/wavex-os-server/src/lib/claude-quota-preflight.ts + const here = fileURLToPath(import.meta.url); + return join(here, "..", "..", "..", "..", ".."); +} + +function claudeBin(): string { + return process.env.WAVEX_OS_CLAUDE_BIN + ?? process.env.PAPERCLIP_HANDOFF_WRAPPER + ?? join(repoRoot(), "scripts", "ops", "claude-keychain-wrapper.sh"); +} + +/** Classify a claude -p text response into a quota state. */ +export function classifyQuotaResponse(output: string): { + state: QuotaState; + resetHint: string | null; +} { + const text = output.toLowerCase(); + // Auth/login failure — the dominant 2026-05-27 storm cause (174/192 = 91%): + // "subtype=success: Your organization does not have access to Claude. + // Please login again or contact your administrator" + // claude exits success with this text, so it reads like a normal completion + // unless inspected. Model-agnostic (failed runs were already on opus) — the + // opus switch + quota gates do NOT cover it. Re-auth via `claude /login`. + if (/does not have access/.test(text) || /please login again/.test(text)) { + return { state: "auth_failed", resetHint: null }; + } + // Hard usage-limit ceiling. Anthropic phrasing: + // "You've hit your Sonnet limit · resets May 24 at 6am (...)" + // "You've hit your usage limit · resets ..." + if (/hit your .*limit/.test(text) || (/\blimit\b/.test(text) && /\bresets?\b/.test(text))) { + const m = output.match(/resets?\s+([^()\n]+?)(?:\s*\(|$)/i); + return { state: "exhausted", resetHint: m ? m[1].trim() : null }; + } + // Transient server-side throttle — NOT a quota ceiling. + // "API Error: Server is temporarily limiting requests (not your usage limit)" + if (/temporarily limiting/.test(text) || /\brate limited\b/.test(text)) { + return { state: "transient", resetHint: null }; + } + return { state: "ok", resetHint: null }; +} + +/** Issue a 1-token canary to the model and classify the result. */ +export function probeClaudeQuota( + model: string, + timeoutMs = PROBE_TIMEOUT_MS, +): Promise { + return new Promise((resolve) => { + const bin = claudeBin(); + const child = spawn( + bin, + ["--print", "-", "--model", model, "--dangerously-skip-permissions"], + { stdio: ["pipe", "pipe", "pipe"] }, + ); + let stdout = ""; + let stderr = ""; + child.stdout?.on("data", (d) => { stdout += d.toString(); }); + child.stderr?.on("data", (d) => { stderr += d.toString(); }); + + const timer = setTimeout(() => { + try { child.kill("SIGTERM"); } catch { /* ignore */ } + resolve({ + state: "unknown", + model, + detail: `quota probe timed out after ${timeoutMs}ms`, + resetHint: null, + }); + }, timeoutMs); + + child.on("error", (err) => { + clearTimeout(timer); + resolve({ + state: "unknown", + model, + detail: `quota probe could not spawn claude: ${err.message}`, + resetHint: null, + }); + }); + + child.on("close", () => { + clearTimeout(timer); + const combined = `${stdout}\n${stderr}`; + const { state, resetHint } = classifyQuotaResponse(combined); + const detail = + state === "exhausted" + ? `Claude ${model} quota exhausted${resetHint ? ` — resets ${resetHint}` : ""}` + : state === "auth_failed" + ? "Claude login invalid — organization does not have access; run `claude /login` to re-auth" + : state === "transient" + ? `Claude is temporarily rate-limiting requests (transient, not a quota ceiling)` + : state === "ok" + ? `Claude ${model} responded — quota available` + : `quota probe inconclusive`; + resolve({ state, model, detail, resetHint }); + }); + + // Minimal prompt — we only care whether the model answers or returns + // a limit envelope, not the content. + child.stdin?.write("ping"); + child.stdin?.end(); + }); +} + +/** The model the fleet's agents run under (set at handoff time). */ +export function fleetAgentModel(): string { + return process.env.WAVEX_AGENT_MODEL ?? "claude-sonnet-4-6"; +} diff --git a/packages/wavex-os-server/src/mission-control/inbound-quality-sampler.ts b/packages/wavex-os-server/src/mission-control/inbound-quality-sampler.ts new file mode 100644 index 00000000..3336affc --- /dev/null +++ b/packages/wavex-os-server/src/mission-control/inbound-quality-sampler.ts @@ -0,0 +1,166 @@ +/** Mission Control — Inbound-quality scoreboard sampler (WAVAAAAA-141). + * + * Reads per-source inbound-quality rows from the tenant Supabase via the + * SECURITY DEFINER RPC `public.wavex_os_compute_inbound_quality()` and + * writes one kpi_snapshots row per token per week into the wavex-os + * internal db. Also emits a per-channel `inbound_unknown_share_` + * data-quality row and surfaces channels whose unknown_share exceeded the + * 10% threshold (the caller forwards those to the Telegram board). + * + * Idempotency contract (per WAVAAAAA-141 §3): the writer deletes any prior + * row matching (companyId, kpiName, metadata->>'window_end') before + * inserting. Re-running the sampler for the same window is safe. + * + * Sister to kpi-sampler.ts. Same kpiSnapshots insert idiom as + * bridge/finalize-bridge.ts:281. + * + * Cron: Sundays 23:00 UTC (`0 23 * * 0`). Trigger via + * POST /api/mission-control/:companyId/sample-inbound-quality. + */ + +import { type Db, getDb, kpiSnapshots } from "@wavex-os/db"; +import { createClient, type SupabaseClient } from "@supabase/supabase-js"; +import { sql } from "drizzle-orm"; + +export interface InboundQualityRow { + source: string; + inbound_count: number; + cohort_size: number; + fbc14: number; + rr60: number; + inbound_quality: number; + unknown_share: number; + channel: string; + confidence: "captured" | "inferred" | "legacy"; + bucket: "ranked" | "insufficient_data"; + window_start: string; + window_end: string; +} + +export interface InboundQualitySampleResult { + /** Number of per-source rows written. */ + perSourceRows: number; + /** Number of per-channel data-quality rows written. */ + perChannelRows: number; + /** Channels whose unknown_share > 10% this window — caller alerts. */ + unknownShareAlerts: Array<{ channel: string; unknownShare: number }>; + /** The window_end the writer used (ISO string). */ + windowEnd: string; +} + +const UNKNOWN_SHARE_ALERT_THRESHOLD = 0.10; + +function getTenantSupabase(): SupabaseClient | null { + const url = process.env.SUPABASE_URL; + const key = process.env.SUPABASE_SERVICE_ROLE_KEY; + if (!url || !key) return null; + return createClient(url, key, { auth: { persistSession: false } }); +} + +/** Encode a float (0..1) as bigint micros. Round half-to-even. */ +function micros(value: number): bigint { + if (!Number.isFinite(value)) return 0n; + return BigInt(Math.round(value * 1_000_000)); +} + +export async function sampleInboundQuality( + companyId: string, + opts: { db?: Db; supabase?: SupabaseClient; windowEnd?: Date } = {}, +): Promise { + const db = opts.db ?? (await getDb()); + const supabase = opts.supabase ?? getTenantSupabase(); + if (!supabase) { + throw new Error( + "sampleInboundQuality: tenant Supabase not configured (SUPABASE_URL / SUPABASE_SERVICE_ROLE_KEY)", + ); + } + + const { data, error } = await supabase.rpc("wavex_os_compute_inbound_quality", { + p_window_end: opts.windowEnd?.toISOString() ?? null, + }); + if (error) { + throw new Error(`compute_inbound_quality RPC failed: ${error.message}`); + } + const rows = (data ?? []) as InboundQualityRow[]; + if (rows.length === 0) { + return { perSourceRows: 0, perChannelRows: 0, unknownShareAlerts: [], windowEnd: "" }; + } + + const windowEnd = rows[0]!.window_end; + const windowStart = rows[0]!.window_start; + const now = new Date(); + + // ── per-source rows ────────────────────────────────────────────── + const perSourceKpiNames = rows.map((r) => `inbound_quality_${r.source}`); + + // ── per-channel unknown-share rows ─────────────────────────────── + const channelUnknownShare = new Map(); + for (const r of rows) { + if (!channelUnknownShare.has(r.channel)) { + channelUnknownShare.set(r.channel, r.unknown_share); + } + } + const perChannelKpiNames = Array.from(channelUnknownShare.keys()).map( + (ch) => `inbound_unknown_share_${ch}`, + ); + + // ── idempotency: drop prior rows matching this window ─────────── + const allKpiNames = [...perSourceKpiNames, ...perChannelKpiNames]; + if (allKpiNames.length > 0) { + await db.delete(kpiSnapshots).where( + sql`${kpiSnapshots.companyId} = ${companyId} + and ${kpiSnapshots.kpiName} in (${sql.join(allKpiNames.map((n) => sql`${n}`), sql`, `)}) + and ${kpiSnapshots.metadata}->>'window_end' = ${windowEnd}`, + ); + } + + // ── insert per-source rows ────────────────────────────────────── + const sourceInserts = rows.map((r) => ({ + companyId, + kpiName: `inbound_quality_${r.source}`, + value: micros(r.inbound_quality), + measuredAt: now, + metadata: { + source: r.source, + fbc14: r.fbc14, + rr60: r.rr60, + inbound_count: r.inbound_count, + cohort_size: r.cohort_size, + confidence: r.confidence, + bucket: r.bucket, + window_start: windowStart, + window_end: windowEnd, + }, + })); + if (sourceInserts.length > 0) await db.insert(kpiSnapshots).values(sourceInserts); + + // ── insert per-channel data-quality rows ──────────────────────── + const channelInserts = Array.from(channelUnknownShare.entries()).map(([channel, share]) => ({ + companyId, + kpiName: `inbound_unknown_share_${channel}`, + value: micros(share), + measuredAt: now, + metadata: { + channel, + unknown_share: share, + window_start: windowStart, + window_end: windowEnd, + }, + })); + if (channelInserts.length > 0) await db.insert(kpiSnapshots).values(channelInserts); + + // ── compute alerts (caller posts to Telegram board) ───────────── + const unknownShareAlerts: Array<{ channel: string; unknownShare: number }> = []; + for (const [channel, share] of channelUnknownShare) { + if (share > UNKNOWN_SHARE_ALERT_THRESHOLD) { + unknownShareAlerts.push({ channel, unknownShare: share }); + } + } + + return { + perSourceRows: sourceInserts.length, + perChannelRows: channelInserts.length, + unknownShareAlerts, + windowEnd, + }; +} diff --git a/packages/wavex-os-server/src/mission-control/pool-b-health.ts b/packages/wavex-os-server/src/mission-control/pool-b-health.ts index 19d47b56..ff09592e 100644 --- a/packages/wavex-os-server/src/mission-control/pool-b-health.ts +++ b/packages/wavex-os-server/src/mission-control/pool-b-health.ts @@ -145,7 +145,7 @@ export async function recentPoolBInferences(limit = 20, fresh = false): Promise< // via the companion migration, NOT via PostgREST's anon/authenticated // path). Falls back to RPC if direct schema access fails. const { data, error } = await (sb as unknown as { - schema: (s: string) => typeof sb; + schema: (s: string) => NonNullable; }).schema("wavex_os").from("usage_ledger") .select("ran_at, model, status, prompt_tokens, completion_tokens, cost_cents, request_id, device_id, error_class") .eq("pool", "B") @@ -179,7 +179,7 @@ export async function devicesWithLastInference(fresh = false): Promise typeof sb }).schema("wavex_os"); + const wavex = (sb as unknown as { schema: (s: string) => NonNullable }).schema("wavex_os"); const { data: devices, error: devErr } = await wavex.from("os_devices") .select("id, name, hostname, os_version, created_at"); if (devErr || !devices) return []; @@ -227,7 +227,7 @@ export async function pendingPairings(fresh = false): Promise typeof sb }) + const { data, error } = await (sb as unknown as { schema: (s: string) => NonNullable }) .schema("wavex_os").from("os_device_pairings") .select("user_code, hostname, status, created_at, expires_at") .gt("expires_at", new Date().toISOString()) @@ -250,7 +250,7 @@ export async function dailyPoolBSpend(days = 14, fresh = false): Promise typeof sb }) + const { data, error } = await (sb as unknown as { schema: (s: string) => NonNullable }) .schema("wavex_os").from("usage_ledger") .select("ran_at, subscription_id, prompt_tokens, completion_tokens, cost_cents, status") .eq("pool", "B") @@ -306,7 +306,7 @@ export async function operatorQuotaStatus(fresh = false): Promise typeof sb; + schema: (s: string) => NonNullable; }).schema("wavex_os").from("usage_ledger") .select("ran_at, prompt_tokens, completion_tokens, cost_cents, status") .eq("pool", "B") @@ -364,7 +364,7 @@ export async function installFunnelSummary(fresh = false): Promise typeof sb }).schema("wavex_os"); + const wavex = (sb as unknown as { schema: (s: string) => NonNullable }).schema("wavex_os"); const since = new Date(Date.now() - 24 * 60 * 60_000).toISOString(); // 1. Pairings initiated in last 24h diff --git a/packages/wavex-os-server/src/routes/activate.ts b/packages/wavex-os-server/src/routes/activate.ts index 11890ff6..2d8701bc 100644 --- a/packages/wavex-os-server/src/routes/activate.ts +++ b/packages/wavex-os-server/src/routes/activate.ts @@ -222,7 +222,50 @@ export function registerActivateRoute(app: FastifyInstance): void { // handoff fires. Self-heals without requiring a wavex restart. const { detectAndConfigurePaperclip } = await import("../lib/paperclip-detect.js"); await detectAndConfigurePaperclip(); - const handoff = await handoffToPaperclip(manifest, companyId).catch((e) => ({ + + // G1 — quota pre-flight. Spawning the full C-Suite as live Paperclip + // agents means N concurrent claude heartbeats. If the model bucket + // the fleet uses is already exhausted, every one of those fails with + // "You've hit your limit" (observed 2026-05-19: 35 agents → + // 50 doomed runs). The DB bridge above already ran (agents exist + + // are recoverable), so we gate ONLY the live handoff: probe the + // fleet model with a 1-token canary, and if it's a hard ceiling, + // skip the spawn and tell the operator when it resets. Opt out with + // WAVEX_AGENT_QUOTA_PREFLIGHT=0. Transient 429s do NOT block — the + // heartbeat retry/backoff handles those. + // Halts on a hard quota ceiling ("exhausted") OR an auth/login failure + // ("auth_failed") — the latter caused 91% of the 2026-05-27 storm and is + // model-agnostic, so it must gate the spawn too. + let quotaSkip: { model: string; detail: string; resetHint: string | null; state: "exhausted" | "auth_failed" } | null = null; + if (process.env.WAVEX_AGENT_QUOTA_PREFLIGHT !== "0") { + const { probeClaudeQuota, fleetAgentModel } = await import("../lib/claude-quota-preflight.js"); + const model = fleetAgentModel(); + const probe = await probeClaudeQuota(model).catch(() => null); + if (probe?.state === "exhausted" || probe?.state === "auth_failed") { + quotaSkip = { model, detail: probe.detail, resetHint: probe.resetHint, state: probe.state }; + } + } + + const handoff = quotaSkip + ? { + enabled: true, + paperclipUrl: process.env.PAPERCLIP_HANDOFF_URL ?? null, + paperclipCompanyId: null, + created: [], + skipped: [], + errors: [{ + slot: "", + message: + quotaSkip.state === "auth_failed" + ? `Live agent handoff skipped — ${quotaSkip.detail}. Agents are hired in the DB and recoverable; ` + + `fix the Claude login (run \`claude /login\` / re-auth the keychain wrapper) and re-run activate. ` + + `Set WAVEX_AGENT_QUOTA_PREFLIGHT=0 to bypass this check.` + : `Live agent handoff skipped — ${quotaSkip.detail}. Agents are hired in the DB and recoverable; ` + + `re-run activate once quota resets, or set WAVEX_AGENT_MODEL to a model with headroom ` + + `(e.g. opus) and retry. Set WAVEX_AGENT_QUOTA_PREFLIGHT=0 to bypass this check.`, + }], + } + : await handoffToPaperclip(manifest, companyId).catch((e) => ({ enabled: true, paperclipUrl: process.env.PAPERCLIP_HANDOFF_URL ?? null, paperclipCompanyId: null, diff --git a/packages/wavex-os-server/src/routes/credentials.ts b/packages/wavex-os-server/src/routes/credentials.ts index 9250510b..2abfa1d5 100644 --- a/packages/wavex-os-server/src/routes/credentials.ts +++ b/packages/wavex-os-server/src/routes/credentials.ts @@ -564,6 +564,7 @@ export function registerCredentialRoutes(app: FastifyInstance): void { slug: t.slug, name: t.displayName, category: t.category, + logo: t.logo, })); _cachedCatalog = { ts: now, rows: fallback, source: "curated" }; return reply.send({ ok: true, rows: fallback, source: "curated", cached: false }); diff --git a/packages/wavex-os-server/src/routes/mission-control.ts b/packages/wavex-os-server/src/routes/mission-control.ts index 6ce190ef..985b14d3 100644 --- a/packages/wavex-os-server/src/routes/mission-control.ts +++ b/packages/wavex-os-server/src/routes/mission-control.ts @@ -38,6 +38,7 @@ import { type DeclareKpiImpactInput, } from "../mission-control/kpi-impacts.js"; import { sampleAllKpis } from "../mission-control/kpi-sampler.js"; +import { sampleInboundQuality } from "../mission-control/inbound-quality-sampler.js"; import { buildImpactGraph, buildImpactSummary, @@ -154,6 +155,42 @@ function parseDate(raw: unknown): Date | undefined { return Number.isNaN(d.getTime()) ? undefined : d; } +async function postInboundQualityBoardAlert( + alerts: Array<{ channel: string; unknownShare: number }>, + windowEnd: string, +): Promise { + const token = process.env.TELEGRAM_BOT_TOKEN ?? process.env.WAVEX_OPS_TELEGRAM_BOT_TOKEN; + const chat = process.env.TELEGRAM_CHAT_ID ?? process.env.WAVEX_OPS_TELEGRAM_CHAT_ID; + if (!token || !chat) { + console.warn("[inbound-quality] Telegram credentials not set — board alert skipped"); + return; + } + const lines = alerts.map( + (a) => + `[inbound-quality] channel=${a.channel} unknown_share=${(a.unknownShare * 100).toFixed(1)}% week_ending=${windowEnd}`, + ); + const text = lines.join("\n"); + try { + const ctrl = new AbortController(); + const t = setTimeout(() => ctrl.abort(), 10_000); + const res = await fetch(`https://api.telegram.org/bot${encodeURIComponent(token)}/sendMessage`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ chat_id: chat, text }), + signal: ctrl.signal, + }); + clearTimeout(t); + if (!res.ok) { + const detail = await res.text().catch(() => ""); + console.error(`[inbound-quality] Telegram alert failed: ${res.status} ${detail}`); + } + } catch (e) { + console.error( + `[inbound-quality] Telegram alert error: ${e instanceof Error ? e.message : String(e)}`, + ); + } +} + export function registerMissionControlRoutes(app: FastifyInstance): void { // ── GET /api/mission-control/:companyId/activity ────────────────────── app.get("/api/mission-control/:companyId/activity", async (req, reply) => { @@ -772,6 +809,38 @@ export function registerMissionControlRoutes(app: FastifyInstance): void { }, ); + // WAVAAAAA-245 — inbound-quality scoreboard tick. + // Writes per-source inbound-quality rows + per-channel unknown-share rows, + // then forwards any unknown_share > 10% channel to the Telegram board. + app.post( + "/api/mission-control/:companyId/sample-inbound-quality", + async (req, reply) => { + const ar = authReq(req); + try { + assertBoard(ar); + } catch (e) { + if (e instanceof AuthError) + return reply.status(e.statusCode).send({ error: e.message }); + throw e; + } + const { companyId } = req.params as { companyId: string }; + assertCompanyAccess(ar, companyId); + await ensureBootstrap(); + try { + const result = await sampleInboundQuality(companyId); + if (result.unknownShareAlerts.length > 0) { + await postInboundQualityBoardAlert(result.unknownShareAlerts, result.windowEnd); + } + return { ok: true, ...result }; + } catch (e) { + return reply.status(503).send({ + ok: false, + error: e instanceof Error ? e.message : String(e), + }); + } + }, + ); + app.get("/api/mission-control/:companyId/scoreboard", async (req, reply) => { const ar = authReq(req); try { diff --git a/packages/wavex-os-server/src/routes/reengagement.ts b/packages/wavex-os-server/src/routes/reengagement.ts new file mode 100644 index 00000000..5f53b8e8 --- /dev/null +++ b/packages/wavex-os-server/src/routes/reengagement.ts @@ -0,0 +1,108 @@ +/** Professional re-engagement metrics + manual trigger (WAVAAAAA-63). + * + * GET /api/expansion/reengagement/metrics + * Returns the current re-activation funnel: nudges_sent (real sends), + * nudges_dry_run, reactivated (booking_confirmed within 48h of a real + * send), and reactivation_rate. Used by the CRO / EXPANSION dashboard + * to grade against the >=20% success metric named in the issue. + * + * POST /api/expansion/reengagement/run + * Board-only manual trigger that fires runProfessionalReengagementJob + * once outside the hourly tick. Useful for end-to-end smoke and for + * flipping out of dry-run when CRO / EXPANSION signs off on copy. */ + +import type { FastifyInstance, FastifyRequest, FastifyReply } from "fastify"; +import { assertBoard, AuthError } from "@wavex-os/auth-shim"; +import { runProfessionalReengagementJob } from "../jobs/professional-reengagement.js"; + +interface MetricsRow { + nudges_sent: number; + nudges_dry_run: number; + reactivated: number; + reactivation_rate: number; + mean_post_nudge_booking_value: number | null; + total_post_nudge_gmv: number | null; + pro_aov_baseline_90d: number | null; + aov_delta_pct: number | null; +} + +async function fetchMetrics(windowHours: number): Promise { + const url = process.env.SUPABASE_URL; + const key = process.env.SUPABASE_SERVICE_ROLE_KEY; + if (!url || !key) return null; + const res = await fetch( + `${url}/rest/v1/rpc/wavex_os_professional_reengagement_metrics`, + { + method: "POST", + headers: { + "Content-Type": "application/json", + apikey: key, + Authorization: `Bearer ${key}`, + }, + body: JSON.stringify({ p_window_hours: windowHours }), + }, + ); + if (!res.ok) { + const detail = await res.text().catch(() => ""); + console.error(`[reengagement] metrics RPC failed: ${res.status} ${detail}`); + return null; + } + const rows = (await res.json().catch(() => [])) as MetricsRow[]; + return rows[0] ?? null; +} + +function authReq(req: FastifyRequest) { + return { method: req.method, headers: req.headers as Record }; +} + +export function registerReengagementRoutes(app: FastifyInstance): void { + app.get( + "/api/expansion/reengagement/metrics", + async (req: FastifyRequest, reply: FastifyReply) => { + const windowParam = (req.query as { window_hours?: string } | undefined)?.window_hours; + const windowHours = windowParam ? Number.parseInt(windowParam, 10) : 48; + if (!Number.isFinite(windowHours) || windowHours <= 0) { + return reply.status(400).send({ ok: false, error: "window_hours must be a positive integer" }); + } + const row = await fetchMetrics(windowHours); + if (!row) { + return reply + .status(503) + .send({ ok: false, error: "metrics unavailable (Supabase not configured or RPC failed)" }); + } + return { + ok: true, + window_hours: windowHours, + nudges_sent: row.nudges_sent, + nudges_dry_run: row.nudges_dry_run, + reactivated: row.reactivated, + reactivation_rate: row.reactivation_rate, + target_rate: 0.20, + mean_post_nudge_booking_value: row.mean_post_nudge_booking_value, + total_post_nudge_gmv: row.total_post_nudge_gmv, + pro_aov_baseline_90d: row.pro_aov_baseline_90d, + aov_delta_pct: row.aov_delta_pct, + }; + }, + ); + + app.post( + "/api/expansion/reengagement/run", + async (req: FastifyRequest, reply: FastifyReply) => { + const ar = authReq(req); + try { + assertBoard(ar); + } catch (e) { + if (e instanceof AuthError) return reply.status(e.statusCode).send({ error: e.message }); + throw e; + } + try { + const result = await runProfessionalReengagementJob(); + return { ok: true, ...result }; + } catch (e) { + const msg = e instanceof Error ? e.message : String(e); + return reply.status(500).send({ ok: false, error: msg }); + } + }, + ); +} diff --git a/packages/wavex-os-server/test/claude-quota-preflight.test.ts b/packages/wavex-os-server/test/claude-quota-preflight.test.ts new file mode 100644 index 00000000..be92bc66 --- /dev/null +++ b/packages/wavex-os-server/test/claude-quota-preflight.test.ts @@ -0,0 +1,34 @@ +import { describe, it, expect } from "vitest"; +import { classifyQuotaResponse } from "../src/lib/claude-quota-preflight.js"; + +describe("classifyQuotaResponse", () => { + it("flags the auth/login failure that caused 91% of the 2026-05-27 storm", () => { + const real = + "Claude run failed: subtype=success: Your organization does not have access to Claude. Please login again or contact your administrator"; + expect(classifyQuotaResponse(real).state).toBe("auth_failed"); + }); + + it("matches the 'please login again' phrasing on its own", () => { + expect(classifyQuotaResponse("Please login again").state).toBe("auth_failed"); + }); + + it("flags a hard quota ceiling as exhausted with a reset hint", () => { + const out = "You've hit your Sonnet limit · resets May 24 at 6am (America/Los_Angeles)"; + const r = classifyQuotaResponse(out); + expect(r.state).toBe("exhausted"); + expect(r.resetHint).toBe("May 24 at 6am"); + }); + + it("flags a server-side throttle as transient (not a ceiling)", () => { + const out = "API Error: Server is temporarily limiting requests (not your usage limit)"; + expect(classifyQuotaResponse(out).state).toBe("transient"); + }); + + it("treats a normal completion as ok", () => { + expect(classifyQuotaResponse("Ready when you are — what would you like to work on?").state).toBe("ok"); + }); + + it("does not misclassify auth failures as ok (the pre-fix gap)", () => { + expect(classifyQuotaResponse("organization does not have access to Claude").state).not.toBe("ok"); + }); +}); diff --git a/packages/wavex-os-server/test/reengagement.test.ts b/packages/wavex-os-server/test/reengagement.test.ts new file mode 100644 index 00000000..a948dcb9 --- /dev/null +++ b/packages/wavex-os-server/test/reengagement.test.ts @@ -0,0 +1,149 @@ +/** Tests for GET /api/expansion/reengagement/metrics (WAVAAAAA-63 + WAVAAAAA-213). + * + * Covers: + * - existing fields pass through unchanged + * - new value fields (mean_post_nudge_booking_value, total_post_nudge_gmv, + * pro_aov_baseline_90d, aov_delta_pct) are present in the response + * - value fields are null when the RPC signals no real sends + * - window_hours query param is forwarded to the RPC + * - 503 when Supabase is not configured */ + +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { mkdtempSync, rmSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import Fastify from "fastify"; +import { registerWavexOsRoutes } from "../src/index.js"; + +let app: ReturnType; +let tempDir: string; + +beforeEach(async () => { + tempDir = mkdtempSync(join(tmpdir(), "reengagement-test-")); + process.env.WAVEX_OS_STATE_DIR = tempDir; + process.env.PAPERCLIP_DATA_DIR = tempDir; + process.env.WAVEX_AUTH_MODE = "dev"; + process.env.WAVEX_COMPOSIO_DISABLED = "1"; + app = Fastify({ logger: false }); + registerWavexOsRoutes(app); + await app.ready(); +}); + +afterEach(async () => { + vi.restoreAllMocks(); + await app.close(); + delete process.env.WAVEX_OS_STATE_DIR; + delete process.env.PAPERCLIP_DATA_DIR; + delete process.env.WAVEX_AUTH_MODE; + delete process.env.WAVEX_COMPOSIO_DISABLED; + delete process.env.SUPABASE_URL; + delete process.env.SUPABASE_SERVICE_ROLE_KEY; + rmSync(tempDir, { recursive: true, force: true }); +}); + +function mockSupabaseRpc(row: Record) { + process.env.SUPABASE_URL = "http://supabase.test"; + process.env.SUPABASE_SERVICE_ROLE_KEY = "test-key"; + vi.stubGlobal( + "fetch", + vi.fn().mockResolvedValue({ + ok: true, + json: async () => [row], + text: async () => "", + }), + ); +} + +describe("GET /api/expansion/reengagement/metrics", () => { + it("returns 503 when Supabase is not configured", async () => { + delete process.env.SUPABASE_URL; + const r = await app.inject({ method: "GET", url: "/api/expansion/reengagement/metrics" }); + expect(r.statusCode).toBe(503); + expect(r.json().ok).toBe(false); + }); + + it("passes through existing fields unchanged", async () => { + mockSupabaseRpc({ + nudges_sent: 5, + nudges_dry_run: 12, + reactivated: 2, + reactivation_rate: 0.4, + mean_post_nudge_booking_value: null, + total_post_nudge_gmv: null, + pro_aov_baseline_90d: null, + aov_delta_pct: null, + }); + const r = await app.inject({ method: "GET", url: "/api/expansion/reengagement/metrics" }); + expect(r.statusCode).toBe(200); + const body = r.json(); + expect(body.ok).toBe(true); + expect(body.nudges_sent).toBe(5); + expect(body.nudges_dry_run).toBe(12); + expect(body.reactivated).toBe(2); + expect(body.reactivation_rate).toBe(0.4); + expect(body.target_rate).toBe(0.2); + }); + + it("exposes value metrics when RPC returns booking data (WAVAAAAA-213)", async () => { + mockSupabaseRpc({ + nudges_sent: 3, + nudges_dry_run: 8, + reactivated: 1, + reactivation_rate: 0.3333, + mean_post_nudge_booking_value: 248.50, + total_post_nudge_gmv: 745.50, + pro_aov_baseline_90d: 310.00, + aov_delta_pct: -0.1984, + }); + const r = await app.inject({ method: "GET", url: "/api/expansion/reengagement/metrics" }); + expect(r.statusCode).toBe(200); + const body = r.json(); + expect(body.mean_post_nudge_booking_value).toBe(248.50); + expect(body.total_post_nudge_gmv).toBe(745.50); + expect(body.pro_aov_baseline_90d).toBe(310.00); + expect(body.aov_delta_pct).toBe(-0.1984); + }); + + it("returns null value fields when no real sends in window", async () => { + // RPC returns nulls for value fields when real_sends is empty + mockSupabaseRpc({ + nudges_sent: 0, + nudges_dry_run: 4, + reactivated: 0, + reactivation_rate: 0, + mean_post_nudge_booking_value: null, + total_post_nudge_gmv: null, + pro_aov_baseline_90d: null, + aov_delta_pct: null, + }); + const r = await app.inject({ method: "GET", url: "/api/expansion/reengagement/metrics" }); + expect(r.statusCode).toBe(200); + const body = r.json(); + expect(body.mean_post_nudge_booking_value).toBeNull(); + expect(body.total_post_nudge_gmv).toBeNull(); + expect(body.pro_aov_baseline_90d).toBeNull(); + expect(body.aov_delta_pct).toBeNull(); + }); + + it("forwards window_hours param to the RPC call", async () => { + let capturedUrl = ""; + process.env.SUPABASE_URL = "http://supabase.test"; + process.env.SUPABASE_SERVICE_ROLE_KEY = "test-key"; + vi.stubGlobal( + "fetch", + vi.fn().mockImplementation(async (url: string) => { + capturedUrl = url; + return { ok: true, json: async () => [{ nudges_sent: 0, nudges_dry_run: 0, reactivated: 0, reactivation_rate: 0, mean_post_nudge_booking_value: null, total_post_nudge_gmv: null, pro_aov_baseline_90d: null, aov_delta_pct: null }], text: async () => "" }; + }), + ); + const r = await app.inject({ method: "GET", url: "/api/expansion/reengagement/metrics?window_hours=72" }); + expect(r.statusCode).toBe(200); + expect(r.json().window_hours).toBe(72); + expect(capturedUrl).toContain("wavex_os_professional_reengagement_metrics"); + }); + + it("returns 400 for invalid window_hours", async () => { + const r = await app.inject({ method: "GET", url: "/api/expansion/reengagement/metrics?window_hours=0" }); + expect(r.statusCode).toBe(400); + }); +}); diff --git a/scripts/ops/wavex-quota-circuit-breaker.mjs b/scripts/ops/wavex-quota-circuit-breaker.mjs new file mode 100755 index 00000000..ab22bd33 --- /dev/null +++ b/scripts/ops/wavex-quota-circuit-breaker.mjs @@ -0,0 +1,161 @@ +#!/usr/bin/env node +/** + * WaveX OS — Pool B / fleet quota circuit breaker (G2). + * + * G1 (claude-quota-preflight.ts) stops activate from spawning a fleet + * into an exhausted model bucket. G2 covers the OTHER failure mode: a + * fleet that was already running when the bucket drains. Paperclip's + * heartbeat retry policy lives in the vendored core and treats a + * "You've hit your limit" response as a transient upstream + * error, so it schedules retries — which re-open claude sessions that + * immediately fail again. On 2026-05-19 that produced a 50-run storm. + * + * This breaker runs out-of-band (cron / launchd / manual). Each tick: + * 1. Probe the fleet model with a 1-token canary. + * 2. If the bucket is a HARD ceiling -> pause-fleet (stops the storm), + * record a breaker-tripped marker with the reset hint. + * 3. If the bucket has recovered AND we previously auto-paused -> + * resume-fleet and clear the marker. + * 4. Transient 429s are ignored (heartbeat backoff handles those). + * + * It NEVER resumes a fleet a human paused — only one it tripped itself + * (tracked via the marker file). It does not touch vendored code. + * + * Usage: + * node scripts/ops/wavex-quota-circuit-breaker.mjs + * node scripts/ops/wavex-quota-circuit-breaker.mjs --dry-run + * + * Env: + * WAVEX_AGENT_MODEL model the fleet uses (default claude-sonnet-4-6) + * PAPERCLIP_BASE_URL default http://127.0.0.1:3100 + * WAVEX_OS_CLAUDE_BIN / + * PAPERCLIP_HANDOFF_WRAPPER claude binary (default keychain wrapper) + */ +import { spawn } from "node:child_process"; +import { mkdirSync, readFileSync, writeFileSync, existsSync, rmSync } from "node:fs"; +import { homedir } from "node:os"; +import { dirname, join } from "node:path"; +import { fileURLToPath } from "node:url"; + +const PROBE_TIMEOUT_MS = 25_000; +const MODEL = process.env.WAVEX_AGENT_MODEL ?? "claude-sonnet-4-6"; +const BASE = process.env.PAPERCLIP_BASE_URL ?? "http://127.0.0.1:3100"; +const REPO_ROOT = join(dirname(fileURLToPath(import.meta.url)), "..", ".."); +const STATE_DIR = join(homedir(), ".wavex-os", "state"); +const args = process.argv.slice(2); +const DRY_RUN = args.includes("--dry-run"); +const COMPANY = args.find((a) => !a.startsWith("--")); + +if (!COMPANY) { + console.error("usage: wavex-quota-circuit-breaker.mjs [--dry-run]"); + process.exit(2); +} + +const markerFile = join(STATE_DIR, `quota-breaker-${COMPANY}.json`); + +function claudeBin() { + return ( + process.env.WAVEX_OS_CLAUDE_BIN ?? + process.env.PAPERCLIP_HANDOFF_WRAPPER ?? + join(REPO_ROOT, "scripts", "ops", "claude-keychain-wrapper.sh") + ); +} + +/** Shared classifier — keep in sync with + * packages/wavex-os-server/src/lib/claude-quota-preflight.ts. */ +export function classifyQuotaResponse(output) { + const text = output.toLowerCase(); + // Auth/login failure — dominant 2026-05-27 storm cause (91%), model-agnostic. + if (/does not have access/.test(text) || /please login again/.test(text)) { + return { state: "auth_failed", resetHint: null }; + } + if (/hit your .*limit/.test(text) || (/\blimit\b/.test(text) && /\bresets?\b/.test(text))) { + const m = output.match(/resets?\s+([^()\n]+?)(?:\s*\(|$)/i); + return { state: "exhausted", resetHint: m ? m[1].trim() : null }; + } + if (/temporarily limiting/.test(text) || /\brate limited\b/.test(text)) { + return { state: "transient", resetHint: null }; + } + return { state: "ok", resetHint: null }; +} + +function probe() { + return new Promise((resolve) => { + const child = spawn( + claudeBin(), + ["--print", "-", "--model", MODEL, "--dangerously-skip-permissions"], + { stdio: ["pipe", "pipe", "pipe"] }, + ); + let out = ""; + child.stdout?.on("data", (d) => { out += d.toString(); }); + child.stderr?.on("data", (d) => { out += d.toString(); }); + const timer = setTimeout(() => { + try { child.kill("SIGTERM"); } catch { /* ignore */ } + resolve({ state: "unknown", resetHint: null }); + }, PROBE_TIMEOUT_MS); + child.on("error", () => { clearTimeout(timer); resolve({ state: "unknown", resetHint: null }); }); + child.on("close", () => { clearTimeout(timer); resolve(classifyQuotaResponse(out)); }); + child.stdin?.write("ping"); + child.stdin?.end(); + }); +} + +async function fleetAction(action) { + const res = await fetch(`${BASE}/api/companies/${COMPANY}/${action}-fleet`, { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ reason: `quota-circuit-breaker: ${action}` }), + }); + if (!res.ok) throw new Error(`${action}-fleet HTTP ${res.status}`); + return res.json().catch(() => ({})); +} + +function readMarker() { + try { return JSON.parse(readFileSync(markerFile, "utf8")); } catch { return null; } +} +function writeMarker(obj) { + mkdirSync(STATE_DIR, { recursive: true }); + writeFileSync(markerFile, JSON.stringify(obj, null, 2)); +} +function clearMarker() { + if (existsSync(markerFile)) rmSync(markerFile); +} + +(async () => { + const { state, resetHint } = await probe(); + const tripped = readMarker(); + const now = new Date().toISOString(); + + if (state === "exhausted" || state === "auth_failed") { + const reason = state === "auth_failed" + ? "AUTH FAILED (org access / login)" + : `quota EXHAUSTED (resets ${resetHint ?? "?"})`; + if (tripped) { + console.log(`[breaker] still ${state} (${MODEL}); fleet already paused since ${tripped.paused_at}.`); + return; + } + console.log(`[breaker] ${MODEL} ${reason} — pausing fleet ${COMPANY}`); + if (state === "auth_failed") console.log("[breaker] FIX: run `claude /login` / re-auth the keychain wrapper, then the breaker auto-resumes once a canary succeeds."); + if (DRY_RUN) { console.log("[breaker] dry-run: would pause-fleet"); return; } + const r = await fleetAction("pause").catch((e) => ({ error: String(e) })); + writeMarker({ paused_at: now, model: MODEL, state, reset_hint: resetHint, result: r }); + console.log(`[breaker] paused. ${JSON.stringify(r).slice(0, 200)}`); + return; + } + + if (state === "ok") { + if (tripped) { + console.log(`[breaker] ${MODEL} quota RECOVERED — resuming fleet ${COMPANY} (auto-paused ${tripped.paused_at})`); + if (DRY_RUN) { console.log("[breaker] dry-run: would resume-fleet"); return; } + const r = await fleetAction("resume").catch((e) => ({ error: String(e) })); + clearMarker(); + console.log(`[breaker] resumed. ${JSON.stringify(r).slice(0, 200)}`); + return; + } + console.log(`[breaker] ${MODEL} quota ok; fleet not breaker-paused. no-op.`); + return; + } + + // transient | unknown — do not act; heartbeat backoff / next tick handles it. + console.log(`[breaker] state=${state} for ${MODEL} — no action (transient/inconclusive).`); +})(); diff --git a/supabase/migrations/20260520000001_wavex_os_user_event_stream.sql b/supabase/migrations/20260520000001_wavex_os_user_event_stream.sql new file mode 100644 index 00000000..e083dfbb --- /dev/null +++ b/supabase/migrations/20260520000001_wavex_os_user_event_stream.sql @@ -0,0 +1,254 @@ +-- WaveX OS — Unified user event stream (WAVAAAAA-25) +-- +-- CDO/TELEMETRY deliverable: a single time-ordered event stream keyed by +-- auth.users.id (uuid) that fuses signals from the three real customer- +-- facing surfaces: +-- +-- • Telegram concierge bot (@wavexcardbot) → wavex_os.telegram_events +-- • Stripe billing → wavex_os.stripe_webhook_events +-- joined via subscriptions +-- • Supabase product surfaces → auth_events, wizard_events, +-- product_activation_events +-- +-- The stream is the substrate for retention, activation, GMV-attribution +-- and on_fire/anomaly detection. One row per emitted event; immutable. +-- +-- Why a view (not a materialized table): +-- • Sources are append-only — no double-count risk. +-- • Volume at pre-PMF stage is small enough to query live. +-- • A materialized view can be layered on top later without breaking +-- the contract on this view's column set. +-- +-- Contract (do not break without bumping a new view): +-- user_id uuid — auth.users.id when resolvable, else NULL +-- source text — 'telegram' | 'stripe' | 'supabase' +-- surface text — finer-grained origin (e.g. 'telegram.bot', +-- 'stripe.subscription', 'supabase.wizard') +-- event_type text — verb-style identifier scoped to surface +-- occurred_at timestamptz — when the event happened (source clock) +-- external_id text — best-effort foreign key into source +-- (telegram message id, stripe evt_*, etc.) +-- external_user text — source-native user handle (telegram +-- user id, stripe customer id, email) +-- payload jsonb — raw event-specific context + +-- ─── telegram_events ──────────────────────────────────────────────────── +-- New table. Populated by the Telegram bot worker on every inbound update +-- and outbound concierge reply. user_id is best-effort resolved at insert +-- time from auth.users by telegram_user_id; left NULL when the chatter +-- hasn't linked an account yet (cold-acquisition funnel). + +create table if not exists wavex_os.telegram_events ( + id uuid primary key default gen_random_uuid(), + user_id uuid references auth.users(id) on delete set null, + telegram_user_id text not null, + chat_id text, + event_type text not null check (event_type in ( + 'message_in', -- inbound user message + 'message_out', -- outbound bot reply + 'command', -- /start, /help, ... + 'callback_query', -- inline keyboard button + 'booking_intent', -- bot parsed a booking request + 'booking_confirmed', -- booking flowed through to partner + 'booking_cancelled', + 'concierge_handoff', -- bot escalated to human ops + 'link_account', -- telegram_user_id ↔ auth.users + 'unlink_account' + )), + external_id text, -- telegram message id / update id + occurred_at timestamptz not null default now(), + payload jsonb not null default '{}'::jsonb, + created_at timestamptz not null default now() +); + +create index if not exists telegram_events_user_idx + on wavex_os.telegram_events(user_id, occurred_at desc) + where user_id is not null; + +create index if not exists telegram_events_tg_user_idx + on wavex_os.telegram_events(telegram_user_id, occurred_at desc); + +create index if not exists telegram_events_type_idx + on wavex_os.telegram_events(event_type, occurred_at desc); + +create unique index if not exists telegram_events_external_id_uniq + on wavex_os.telegram_events(external_id) + where external_id is not null; + +comment on table wavex_os.telegram_events is + 'Append-only log of every Telegram concierge interaction. user_id is ' + 'resolved best-effort from telegram_user_id; rows for un-linked chatters ' + 'keep user_id NULL until link_account fires. Drives the demand-side ' + 'half of the booking_gmv attribution chain.'; + +-- ─── user_event_stream view ───────────────────────────────────────────── +-- Single time-ordered view fusing Telegram + Stripe + Supabase signals +-- under a stable contract. Consumers (retention dashboards, on_fire +-- detectors, GMV attribution) should select against this view, not the +-- underlying tables, so the substrate can evolve underneath. + +create or replace view wavex_os.user_event_stream as + -- Telegram concierge surface + select + t.user_id as user_id, + 'telegram'::text as source, + 'telegram.bot'::text as surface, + t.event_type as event_type, + t.occurred_at as occurred_at, + t.external_id as external_id, + t.telegram_user_id as external_user, + t.payload || jsonb_build_object('chat_id', t.chat_id) as payload + from wavex_os.telegram_events t + + union all + + -- Stripe billing surface — webhook events joined to subscriptions + -- to recover the auth.users.id. Events that don't carry a Stripe + -- customer/subscription (rare housekeeping events) are surfaced + -- with user_id NULL so they still appear in the stream. + select + s.user_id as user_id, + 'stripe'::text as source, + 'stripe.webhook'::text as surface, + w.type as event_type, + w.processed_at as occurred_at, + w.id as external_id, + coalesce( + s.stripe_customer_id, + w.payload #>> '{data,object,customer}' + ) as external_user, + jsonb_build_object( + 'api_version', w.api_version, + 'stripe_subscription_id', + coalesce( + s.stripe_subscription_id, + w.payload #>> '{data,object,subscription}', + w.payload #>> '{data,object,id}' + ), + 'tier', s.tier, + 'status', s.status, + 'processing_error', w.processing_error + ) as payload + from wavex_os.stripe_webhook_events w + left join wavex_os.subscriptions s + on s.stripe_customer_id = (w.payload #>> '{data,object,customer}') + or s.stripe_subscription_id = coalesce( + w.payload #>> '{data,object,subscription}', + w.payload #>> '{data,object,id}' + ) + + union all + + -- Supabase product surface — auth signups (with UTM attribution). + -- auth_events stores user_id as text; cast to uuid when it parses. + select + case + when a.user_id ~* '^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$' + then a.user_id::uuid + else null + end as user_id, + 'supabase'::text as source, + 'supabase.auth'::text as surface, + a.event_type as event_type, + a.created_at as occurred_at, + a.id::text as external_id, + a.email as external_user, + jsonb_build_object( + 'utm_campaign', a.utm_campaign, + 'utm_source', a.utm_source, + 'ref', a.ref, + 'resend_fired', a.resend_fired + ) as payload + from wavex_os.auth_events a + + union all + + -- Supabase product surface — wizard funnel telemetry. + select + case + when w2.user_id ~* '^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$' + then w2.user_id::uuid + else null + end as user_id, + 'supabase'::text as source, + 'supabase.wizard'::text as surface, + w2.event_type as event_type, + w2.created_at as occurred_at, + w2.id::text as external_id, + w2.user_id as external_user, + jsonb_build_object( + 'step', w2.step, + 'last_step', w2.last_step, + 'result_id', w2.result_id, + 'status', w2.status + ) as payload + from wavex_os.wizard_events w2 + + union all + + -- Supabase product surface — activation funnel events. + select + case + when p.user_id ~* '^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$' + then p.user_id::uuid + else null + end as user_id, + 'supabase'::text as source, + 'supabase.activation'::text as surface, + p.event_type as event_type, + p.occurred_at as occurred_at, + p.id::text as external_id, + p.user_id as external_user, + p.payload || jsonb_build_object('company_id', p.company_id) + as payload + from wavex_os.product_activation_events p; + +comment on view wavex_os.user_event_stream is + 'Unified user event stream (WAVAAAAA-25). Time-ordered fusion of ' + 'Telegram concierge, Stripe billing, and Supabase product events keyed ' + 'by auth.users.id when resolvable. Stable column contract: ' + '(user_id, source, surface, event_type, occurred_at, external_id, ' + 'external_user, payload). Substrate for retention, activation, GMV ' + 'attribution and on_fire/anomaly detection.'; + +-- ─── helper: window slice keyed by user ──────────────────────────────── +-- Convenience function used by retention/on_fire detectors to pull a +-- single user's last-N-days of events without pushing complex predicates +-- into every caller. Returns rows in chronological order. + +create or replace function wavex_os.user_event_stream_window( + p_user_id uuid, + p_since timestamptz default (now() - interval '30 days'), + p_until timestamptz default now(), + p_sources text[] default null, + p_event_types text[] default null +) +returns table ( + source text, + surface text, + event_type text, + occurred_at timestamptz, + external_id text, + external_user text, + payload jsonb +) +language sql +stable +set search_path = '' +as $$ + select + s.source, s.surface, s.event_type, s.occurred_at, + s.external_id, s.external_user, s.payload + from wavex_os.user_event_stream s + where s.user_id = p_user_id + and s.occurred_at >= p_since + and s.occurred_at < p_until + and (p_sources is null or s.source = any(p_sources)) + and (p_event_types is null or s.event_type = any(p_event_types)) + order by s.occurred_at asc; +$$; + +comment on function wavex_os.user_event_stream_window(uuid, timestamptz, timestamptz, text[], text[]) is + 'Slice of wavex_os.user_event_stream scoped to a single user and time ' + 'window. Used by retention loops and on_fire detectors so call sites do ' + 'not have to re-implement the same filter shape.'; diff --git a/supabase/migrations/20260520000002_wavex_os_kpi_history.sql b/supabase/migrations/20260520000002_wavex_os_kpi_history.sql new file mode 100644 index 00000000..941e48c9 --- /dev/null +++ b/supabase/migrations/20260520000002_wavex_os_kpi_history.sql @@ -0,0 +1,116 @@ +-- WaveX OS — KPI history sink + booking_gmv snapshot procedure (WAVAAAAA-129) +-- +-- CDO/TELEMETRY deliverable: the substrate the recurring kpi_history routine +-- (WAVAAAAA-1) fires against. One row per snapshot, immutable, indexed by +-- (kpi, captured_at desc) so backfills and time-series queries are cheap. +-- +-- Why a sink table (not a view): KPI snapshots are point-in-time. We +-- explicitly want a historical record we can backfill against and audit +-- against drift, not a live derivation that silently re-computes when the +-- underlying surface mutates. +-- +-- Contract (do not break without bumping a new table/version): +-- id bigint — surrogate pk +-- kpi text — kpi name (e.g. 'booking_gmv') +-- value numeric — measured value at captured_at +-- currency text — ISO 4217 when monetary, else NULL +-- dimensions jsonb — slicer dims (geo, segment, tier, ...) +-- captured_at timestamptz — snapshot wall-clock +-- source text — emitting agent / procedure name + +create table if not exists wavex_os.kpi_history ( + id bigint generated always as identity primary key, + kpi text not null, + value numeric not null, + currency text, + dimensions jsonb not null default '{}'::jsonb, + captured_at timestamptz not null default now(), + source text +); + +create index if not exists kpi_history_kpi_captured_at_idx + on wavex_os.kpi_history(kpi, captured_at desc); + +comment on table wavex_os.kpi_history is + 'Append-only KPI snapshot sink (WAVAAAAA-129). One row per measurement; ' + 'the recurring routine writes here on cadence so we have a historical ' + 'series to drift-check, backfill, and attribute against. Substrate for ' + 'kpi_freshness_seconds and kpi_translation_drift telemetry.'; + +-- ─── snapshot_booking_gmv() ──────────────────────────────────────────── +-- Computes cumulative booking_gmv from public.bookings filtered to +-- status in ('confirmed','completed') and currency='USD', then writes a +-- single row to wavex_os.kpi_history. Idempotent within a 60s window: +-- if the most recent booking_gmv snapshot is younger than 60 seconds we +-- skip the insert and return the existing row. This keeps the routine +-- safe to re-fire on overlapping schedules and during migration replays. + +create or replace function wavex_os.snapshot_booking_gmv() +returns table ( + id bigint, + value numeric, + captured_at timestamptz +) +language plpgsql +set search_path = '' +as $$ +declare + v_gmv numeric; + v_last_at timestamptz; + v_last_id bigint; + v_last_v numeric; +begin + -- Idempotency guard: if the most recent booking_gmv row is younger + -- than 60s, return it instead of inserting a duplicate. + select h.id, h.value, h.captured_at + into v_last_id, v_last_v, v_last_at + from wavex_os.kpi_history h + where h.kpi = 'booking_gmv' + order by h.captured_at desc + limit 1; + + if v_last_at is not null and v_last_at > now() - interval '60 seconds' then + id := v_last_id; + value := v_last_v; + captured_at := v_last_at; + return next; + return; + end if; + + select coalesce(sum(b.amount), 0)::numeric + into v_gmv + from public.bookings b + where b.booking_status in ('confirmed', 'completed') + and b.currency = 'USD'; + + insert into wavex_os.kpi_history (kpi, value, currency, dimensions, source) + values ( + 'booking_gmv', + v_gmv, + 'USD', + jsonb_build_object( + 'booking_status', jsonb_build_array('confirmed', 'completed'), + 'source_table', 'public.bookings' + ), + 'wavex_os.snapshot_booking_gmv' + ) + returning kpi_history.id, kpi_history.value, kpi_history.captured_at + into id, value, captured_at; + + return next; +end; +$$; + +comment on function wavex_os.snapshot_booking_gmv() is + 'Captures cumulative booking_gmv (sum of public.bookings.amount where ' + 'booking_status in (confirmed, completed) and currency = USD) into ' + 'wavex_os.kpi_history. Idempotent within 60s: re-fires return the ' + 'existing row rather than writing a duplicate. Fired by the recurring ' + 'kpi_history routine (WAVAAAAA-1) and on-demand by drift checks.'; + +-- ─── t=0 baseline ────────────────────────────────────────────────────── +-- Required by the issue acceptance criteria: execute the procedure once +-- on landing so a baseline row exists in kpi_history before the routine +-- starts firing. The idempotency guard above keeps replays safe. + +select * from wavex_os.snapshot_booking_gmv(); diff --git a/supabase/migrations/20260520000003_wavex_os_snapshot_booking_gmv_rpc.sql b/supabase/migrations/20260520000003_wavex_os_snapshot_booking_gmv_rpc.sql new file mode 100644 index 00000000..2584f91d --- /dev/null +++ b/supabase/migrations/20260520000003_wavex_os_snapshot_booking_gmv_rpc.sql @@ -0,0 +1,32 @@ +-- WaveX OS — public RPC wrapper for wavex_os.snapshot_booking_gmv (WAVAAAAA-189) +-- +-- The substrate function lives in wavex_os.snapshot_booking_gmv() per +-- WAVAAAAA-129. PostgREST only exposes public, graphql_public, meta_ads, +-- so the daily kpi_history routine (CDO/TELEMETRY run issues) cannot call +-- it without a public-schema RPC wrapper. This migration adds that +-- wrapper, matching the wavex_os_record_company_manifest pattern used by +-- other wavex_os entrypoints. + +create or replace function public.wavex_os_snapshot_booking_gmv() +returns table ( + id bigint, + value numeric, + captured_at timestamptz +) +language plpgsql +security definer +set search_path to 'wavex_os', 'public' +as $$ +begin + return query select * from wavex_os.snapshot_booking_gmv(); +end; +$$; + +comment on function public.wavex_os_snapshot_booking_gmv() is + 'PostgREST-exposed wrapper for wavex_os.snapshot_booking_gmv(). Daily ' + 'kpi_history routine (WAVAAAAA-189) invokes this from CDO/TELEMETRY ' + 'run issues. Inherits the 60s idempotency guard from the underlying ' + 'function.'; + +revoke all on function public.wavex_os_snapshot_booking_gmv() from public; +grant execute on function public.wavex_os_snapshot_booking_gmv() to service_role; diff --git a/supabase/migrations/20260520000004_wavex_os_bookings_fulfillment_instrumentation.sql b/supabase/migrations/20260520000004_wavex_os_bookings_fulfillment_instrumentation.sql new file mode 100644 index 00000000..4c8bffa1 --- /dev/null +++ b/supabase/migrations/20260520000004_wavex_os_bookings_fulfillment_instrumentation.sql @@ -0,0 +1,112 @@ +-- WaveX OS — bookings fulfillment instrumentation (WAVAAAAA-218) +-- +-- Adds the schema surface that the fulfillment-resolution stack needs: +-- +-- 1. fulfillment_status — extend the existing column with the four new +-- terminal states (fulfilled, no_show, cancelled_late, disputed). +-- The existing column may be a TEXT field (current shape in prod +-- per WAVAAAAA-211 forensics) or an enum; this migration normalises +-- to a CHECK-constrained TEXT to avoid the enum-rewrite trap on a +-- live table. Behaviour for the existing 'pending' state is +-- unchanged. +-- +-- 2. fulfilled_at TIMESTAMPTZ NULL — set when fulfillment resolves to +-- 'fulfilled'. Other terminal states (no_show, cancelled_late, +-- disputed) intentionally do NOT set fulfilled_at; they are tracked +-- via fulfillment_status alone so the GMV recogniser remains +-- "fulfilled only". +-- +-- 3. fulfillment_source TEXT NULL — which signal closed the booking. +-- The hybrid resolver priority (WAVAAAAA-211) is +-- partner_api > user_confirm > timer_fallback > operator_manual. +-- +-- All changes are additive: no column drops, no destructive backfill, +-- no data deletion. Existing 12 bookings stay 'pending' until the +-- backfill sibling issue runs. + +begin; + +-- ─── fulfillment_status ──────────────────────────────────────────── +-- Defensive: only add the constraint if the column exists. If a +-- previous enum constraint exists with a different name we leave it +-- untouched and the new CHECK is additive. + +do $$ +begin + if not exists ( + select 1 + from information_schema.columns + where table_schema = 'public' + and table_name = 'bookings' + and column_name = 'fulfillment_status' + ) then + alter table public.bookings + add column fulfillment_status text not null default 'pending'; + end if; +end$$; + +alter table public.bookings + drop constraint if exists bookings_fulfillment_status_check; + +alter table public.bookings + add constraint bookings_fulfillment_status_check + check (fulfillment_status in ( + 'pending', + 'fulfilled', + 'no_show', + 'cancelled_late', + 'disputed' + )); + +-- ─── fulfilled_at ────────────────────────────────────────────────── + +alter table public.bookings + add column if not exists fulfilled_at timestamptz null; + +-- ─── fulfillment_source ──────────────────────────────────────────── + +alter table public.bookings + add column if not exists fulfillment_source text null; + +alter table public.bookings + drop constraint if exists bookings_fulfillment_source_check; + +alter table public.bookings + add constraint bookings_fulfillment_source_check + check (fulfillment_source is null or fulfillment_source in ( + 'partner_api', + 'user_confirm', + 'timer_fallback', + 'operator_manual' + )); + +-- ─── index for the resolver lookup ───────────────────────────────── +-- The hourly worker queries: +-- where fulfillment_status = 'pending' +-- and paid_at is not null +-- and now() > booking_time + experience_duration + interval '24 hours' +-- A partial index on the pending rows keeps the scan cheap as the +-- bookings table grows. + +create index if not exists bookings_fulfillment_pending_idx + on public.bookings (booking_time) + where fulfillment_status = 'pending'; + +-- ─── comments ────────────────────────────────────────────────────── + +comment on column public.bookings.fulfillment_status is + 'Lifecycle state of the booking fulfillment promise. Set to fulfilled ' + 'when partner_api confirms, user user_confirms, the 24h timer_fallback ' + 'fires, or an operator manually resolves. See WAVAAAAA-211 for the ' + 'hybrid-signal decision.'; + +comment on column public.bookings.fulfilled_at is + 'Timestamp at which fulfillment_status transitioned to fulfilled. ' + 'Null for pending and for terminal-non-fulfilled states ' + '(no_show / cancelled_late / disputed).'; + +comment on column public.bookings.fulfillment_source is + 'Which signal closed the booking. Priority order during resolution: ' + 'partner_api > user_confirm > timer_fallback > operator_manual.'; + +commit; diff --git a/supabase/migrations/20260520000005_wavex_os_professional_reengagement.sql b/supabase/migrations/20260520000005_wavex_os_professional_reengagement.sql new file mode 100644 index 00000000..58ad323c --- /dev/null +++ b/supabase/migrations/20260520000005_wavex_os_professional_reengagement.sql @@ -0,0 +1,270 @@ +-- WaveX OS — Professional re-engagement nudges (WAVAAAAA-63) +-- +-- Goal: stop grr/nrr leakage by triggering a Telegram concierge re-engagement +-- message to professionals whose last booking_confirmed was >7 days ago. +-- +-- A "professional" here is operationally defined as a user with >=1 +-- booking_confirmed event in wavex_os.telegram_events. We don't carry a +-- separate professionals table yet — the booking history is the segment. +-- +-- This migration adds: +-- • wavex_os.professional_reengagement_nudges — append-only nudge log +-- • wavex_os_list_professionals_with_stale_booking(...) RPC +-- • wavex_os_record_professional_reengagement_nudge(...) RPC +-- • wavex_os_professional_reengagement_metrics(...) RPC +-- +-- The list RPC honours a cooldown window so a professional isn't nudged +-- twice inside the cadence. The metrics RPC computes re-activation rate +-- (booking_confirmed within p_window_hours after nudge) — the success +-- metric named in the issue (>=20%). + +-- ─── nudge log ────────────────────────────────────────────────────────── + +create table if not exists wavex_os.professional_reengagement_nudges ( + id uuid primary key default gen_random_uuid(), + user_id uuid references auth.users(id) on delete set null, + telegram_user_id text not null, + chat_id text not null, + last_booking_at timestamptz not null, + sent_at timestamptz not null default now(), + message_id text, + message_text text not null, + dry_run boolean not null default true, + reactivated_at timestamptz, + reactivation_event_id text, + created_at timestamptz not null default now() +); + +create index if not exists professional_reengagement_nudges_user_idx + on wavex_os.professional_reengagement_nudges(user_id, sent_at desc) + where user_id is not null; + +create index if not exists professional_reengagement_nudges_sent_idx + on wavex_os.professional_reengagement_nudges(sent_at desc); + +comment on table wavex_os.professional_reengagement_nudges is + 'Append-only log of Telegram re-engagement nudges fired at professionals ' + 'with stale booking activity. Joined to wavex_os.telegram_events to ' + 'compute the re-activation success metric (booking_confirmed within 48h ' + 'after sent_at).'; + +-- ─── list candidates ──────────────────────────────────────────────────── +-- Returns one row per professional whose latest booking_confirmed is older +-- than p_gap_days and who has not been nudged within p_cooldown_hours. +-- chat_id is taken from the most recent telegram_event for that user, so +-- the bot has a destination even if the original booking row carried only +-- telegram_user_id. + +create or replace function wavex_os.list_professionals_with_stale_booking( + p_gap_days integer default 7, + p_cooldown_hours integer default 168 -- 7d cooldown between nudges per pro +) +returns table ( + user_id uuid, + telegram_user_id text, + chat_id text, + last_booking_at timestamptz, + last_nudged_at timestamptz +) +language sql +stable +set search_path = '' +as $$ + with last_booking as ( + select + t.user_id, + max(t.occurred_at) as last_booking_at + from wavex_os.telegram_events t + where t.event_type = 'booking_confirmed' + and t.user_id is not null + group by t.user_id + ), + destination as ( + -- pick the most recent telegram_user_id + chat_id per user_id + select distinct on (t.user_id) + t.user_id, + t.telegram_user_id, + t.chat_id + from wavex_os.telegram_events t + where t.user_id is not null + and t.chat_id is not null + order by t.user_id, t.occurred_at desc + ), + last_nudge as ( + select + n.user_id, + max(n.sent_at) as last_nudged_at + from wavex_os.professional_reengagement_nudges n + where n.user_id is not null + group by n.user_id + ) + select + lb.user_id, + d.telegram_user_id, + d.chat_id, + lb.last_booking_at, + ln.last_nudged_at + from last_booking lb + join destination d using (user_id) + left join last_nudge ln using (user_id) + where lb.last_booking_at < (now() - make_interval(days => p_gap_days)) + and (ln.last_nudged_at is null + or ln.last_nudged_at < (now() - make_interval(hours => p_cooldown_hours))) + order by lb.last_booking_at asc; -- nudge the staleest first +$$; + +comment on function wavex_os.list_professionals_with_stale_booking(integer, integer) is + 'Candidate list for the WAVAAAAA-63 Telegram re-engagement nudge. ' + 'Returns professionals whose latest booking_confirmed is older than ' + 'p_gap_days and who have not been nudged in p_cooldown_hours.'; + +-- PostgREST cannot resolve schema-qualified functions, so expose a public +-- wrapper. SECURITY DEFINER lets the service role caller execute through +-- the wavex_os schema without explicit grants on every underlying table. + +create or replace function public.wavex_os_list_professionals_with_stale_booking( + p_gap_days integer default 7, + p_cooldown_hours integer default 168 +) +returns table ( + user_id uuid, + telegram_user_id text, + chat_id text, + last_booking_at timestamptz, + last_nudged_at timestamptz +) +language sql +security definer +set search_path = '' +as $$ + select * from wavex_os.list_professionals_with_stale_booking(p_gap_days, p_cooldown_hours); +$$; + +revoke all on function public.wavex_os_list_professionals_with_stale_booking(integer, integer) from public; +grant execute on function public.wavex_os_list_professionals_with_stale_booking(integer, integer) to service_role; + +-- ─── record a nudge ───────────────────────────────────────────────────── + +create or replace function wavex_os.record_professional_reengagement_nudge( + p_user_id uuid, + p_telegram_user_id text, + p_chat_id text, + p_last_booking_at timestamptz, + p_message_text text, + p_message_id text default null, + p_dry_run boolean default true +) +returns uuid +language sql +volatile +set search_path = '' +as $$ + insert into wavex_os.professional_reengagement_nudges ( + user_id, telegram_user_id, chat_id, last_booking_at, + message_text, message_id, dry_run + ) + values ( + p_user_id, p_telegram_user_id, p_chat_id, p_last_booking_at, + p_message_text, p_message_id, p_dry_run + ) + returning id; +$$; + +create or replace function public.wavex_os_record_professional_reengagement_nudge( + p_user_id uuid, + p_telegram_user_id text, + p_chat_id text, + p_last_booking_at timestamptz, + p_message_text text, + p_message_id text default null, + p_dry_run boolean default true +) +returns uuid +language sql +security definer +set search_path = '' +as $$ + select wavex_os.record_professional_reengagement_nudge( + p_user_id, p_telegram_user_id, p_chat_id, p_last_booking_at, + p_message_text, p_message_id, p_dry_run + ); +$$; + +revoke all on function public.wavex_os_record_professional_reengagement_nudge( + uuid, text, text, timestamptz, text, text, boolean +) from public; +grant execute on function public.wavex_os_record_professional_reengagement_nudge( + uuid, text, text, timestamptz, text, text, boolean +) to service_role; + +-- ─── metrics ──────────────────────────────────────────────────────────── +-- Re-activation rate = nudged professionals who logged a booking_confirmed +-- within p_window_hours after sent_at, over total nudged professionals +-- inside the same time slice. Dry-run rows are excluded from the +-- denominator so the rate reflects real-send efficacy. + +create or replace function wavex_os.professional_reengagement_metrics( + p_window_hours integer default 48, + p_since timestamptz default (now() - interval '30 days') +) +returns table ( + nudges_sent integer, + nudges_dry_run integer, + reactivated integer, + reactivation_rate numeric +) +language sql +stable +set search_path = '' +as $$ + with sent as ( + select n.* + from wavex_os.professional_reengagement_nudges n + where n.sent_at >= p_since + ), + reactivated as ( + select distinct s.id as nudge_id + from sent s + join wavex_os.telegram_events t + on t.user_id = s.user_id + and t.event_type = 'booking_confirmed' + and t.occurred_at > s.sent_at + and t.occurred_at <= (s.sent_at + make_interval(hours => p_window_hours)) + where s.dry_run = false + ) + select + (select count(*)::int from sent where dry_run = false) as nudges_sent, + (select count(*)::int from sent where dry_run = true) as nudges_dry_run, + (select count(*)::int from reactivated) as reactivated, + case + when (select count(*) from sent where dry_run = false) = 0 then 0::numeric + else round( + (select count(*)::numeric from reactivated) + / (select count(*)::numeric from sent where dry_run = false), 4) + end as reactivation_rate; +$$; + +create or replace function public.wavex_os_professional_reengagement_metrics( + p_window_hours integer default 48, + p_since timestamptz default (now() - interval '30 days') +) +returns table ( + nudges_sent integer, + nudges_dry_run integer, + reactivated integer, + reactivation_rate numeric +) +language sql +security definer +set search_path = '' +as $$ + select * from wavex_os.professional_reengagement_metrics(p_window_hours, p_since); +$$; + +revoke all on function public.wavex_os_professional_reengagement_metrics(integer, timestamptz) from public; +grant execute on function public.wavex_os_professional_reengagement_metrics(integer, timestamptz) to service_role; + +comment on function wavex_os.professional_reengagement_metrics(integer, timestamptz) is + 'Aggregate WAVAAAAA-63 nudge efficacy: nudges_sent (real), nudges_dry_run, ' + 'reactivated (booked within p_window_hours after a real send), and the ' + 'reactivation_rate. Drives the >=20% success metric named in the issue.'; diff --git a/supabase/migrations/20260521000002_wavex_os_inbound_quality.sql b/supabase/migrations/20260521000002_wavex_os_inbound_quality.sql new file mode 100644 index 00000000..41518448 --- /dev/null +++ b/supabase/migrations/20260521000002_wavex_os_inbound_quality.sql @@ -0,0 +1,261 @@ +-- WaveX OS — Inbound-quality scoreboard + marketing_campaigns registry +-- (WAVAAAAA-141) +-- +-- CDO/TELEMETRY deliverable. Owns: +-- 1. wavex_os.marketing_campaigns — campaign registry (channel + surface enums) +-- 2. wavex_os.compute_inbound_quality() — weekly compute RPC, returns rows +-- 3. public.wavex_os_compute_inbound_quality() SECURITY DEFINER wrapper +-- +-- Schema-reality reconciliation (vs WAVAAAAA-141 v0 description): +-- The original ticket assumed `public.professionals.first_touch_source`. +-- What actually shipped via WAVAAAAA-206 is the sidecar +-- wavex_os.professional_first_touch (telegram_user_id, user_id, first_touch_source, first_touch_at) +-- with sentinel `unknown_legacy` (not `unknown_unknown`). This migration +-- uses the as-shipped names. The taxonomy v1.1 sentinel-name mismatch is +-- flagged separately on the issue thread; until CMO/CONTENT publishes +-- v1.2, the RPC treats `unknown_legacy` AND `unknown_unknown` as the +-- `legacy` confidence bucket so either spelling is safe. +-- +-- Storage placement: wavex_os schema (not public). This is wavex-os fleet +-- governance data, not tenant-app data. Same Supabase project; isolated +-- namespace consistent with telegram_events / professional_first_touch. + +begin; + +-- ─── marketing_campaigns registry ────────────────────────────────── + +create table if not exists wavex_os.marketing_campaigns ( + campaign_slug text primary key, + owner_agent_id uuid null, + channel text not null, + surface text not null, + launched_at timestamptz not null, + ended_at timestamptz null, + notes text null, + created_at timestamptz not null default now(), + updated_at timestamptz not null default now(), + constraint marketing_campaigns_channel_chk + check (channel in ('seo','social','email','referral','partner','paid','community','direct','unknown')), + -- surface enum mirrors CMO/CONTENT taxonomy v1.1 §4 (closed enum). + -- If §4 expands, this constraint must be updated in a follow-up migration. + constraint marketing_campaigns_surface_chk + check (surface in ( + 'tg_bot','tg_channel','tg_group', + 'ig_reel','ig_post','ig_story', + 'tiktok','youtube','twitter', + 'email','partner_page','landing_page', + 'blog','referral_link','direct','unknown' + )), + constraint marketing_campaigns_slug_format + check (campaign_slug ~ '^[A-Za-z0-9_-]{1,64}$') +); + +create index if not exists marketing_campaigns_channel_idx + on wavex_os.marketing_campaigns (channel); +create index if not exists marketing_campaigns_window_idx + on wavex_os.marketing_campaigns (launched_at desc, ended_at desc nulls first); + +comment on table wavex_os.marketing_campaigns is + 'Campaign registry. CMO/CONTENT, CMO/DEMAND, CMO/BRAND, CMO/ADVOCACY ' + 'register here before publishing content. The (launched_at, ended_at) ' + 'window is used by the inbound-quality scoring RPC to infer first-touch ' + 'sources for legacy professionals whose first_touch_source is ' + 'unknown_legacy.'; + +comment on column wavex_os.marketing_campaigns.campaign_slug is + 'Stable token referenced by /start deep-link params and by ' + 'kpi_snapshots.kpi_name suffixes (e.g. inbound_quality_social_ig_f1miami_reel1).'; + +-- ─── public wrapper for inserts (board-only) ─────────────────────── + +create or replace function public.wavex_os_register_campaign( + p_campaign_slug text, + p_owner_agent_id uuid, + p_channel text, + p_surface text, + p_launched_at timestamptz, + p_ended_at timestamptz, + p_notes text +) +returns wavex_os.marketing_campaigns +language sql +volatile +security definer +set search_path = '' +as $$ + insert into wavex_os.marketing_campaigns ( + campaign_slug, owner_agent_id, channel, surface, launched_at, ended_at, notes + ) + values (p_campaign_slug, p_owner_agent_id, p_channel, p_surface, p_launched_at, p_ended_at, p_notes) + on conflict (campaign_slug) do update + set owner_agent_id = excluded.owner_agent_id, + channel = excluded.channel, + surface = excluded.surface, + launched_at = excluded.launched_at, + ended_at = excluded.ended_at, + notes = excluded.notes, + updated_at = now() + returning *; +$$; + +revoke all on function public.wavex_os_register_campaign(text, uuid, text, text, timestamptz, timestamptz, text) from public; +grant execute on function public.wavex_os_register_campaign(text, uuid, text, text, timestamptz, timestamptz, text) to service_role; + +-- ─── compute_inbound_quality RPC ─────────────────────────────────── +-- Rolling 90d cohort over wavex_os.professional_first_touch joined to +-- public.bookings via user_id. Status filter matches the kpi_history +-- snapshotter (booking_status in 'confirmed','completed'). +-- +-- Output columns map 1:1 to the kpi_snapshots writer in +-- packages/wavex-os-server/src/mission-control/inbound-quality-sampler.ts. +-- The writer encodes (kpi_name, value, metadata) per WAVAAAAA-141 §3. + +create or replace function wavex_os.compute_inbound_quality( + p_window_end timestamptz default date_trunc('week', now() at time zone 'UTC') +) +returns table ( + source text, + inbound_count integer, + cohort_size integer, + fbc14 numeric, + rr60 numeric, + inbound_quality numeric, + unknown_share numeric, + channel text, + confidence text, + bucket text, + window_start timestamptz, + window_end timestamptz +) +language sql +stable +set search_path = '' +as $$ + with bounds as ( + select + (p_window_end - interval '90 days')::timestamptz as w_start, + p_window_end::timestamptz as w_end, + (p_window_end - interval '7 days')::timestamptz as recent_start + ), + -- One row per professional we've ever first-touched within the window. + cohort as ( + select + f.telegram_user_id, + f.user_id, + f.first_touch_source as source, + f.first_touch_at, + f.first_touch_at >= b.recent_start as is_recent + from wavex_os.professional_first_touch f + cross join bounds b + where f.first_touch_at >= b.w_start + and f.first_touch_at < b.w_end + ), + -- Bookings joined by user_id (resolved once the account links). + booked as ( + select + c.telegram_user_id, + c.source, + c.first_touch_at, + c.is_recent, + count(*) filter ( + where bk.booking_status in ('confirmed','completed') + and bk.created_at <= c.first_touch_at + interval '14 days' + ) as bookings_14d, + count(*) filter ( + where bk.booking_status in ('confirmed','completed') + and bk.created_at <= c.first_touch_at + interval '60 days' + ) as bookings_60d + from cohort c + left join public.bookings bk + on c.user_id is not null + and bk.user_id = c.user_id + group by c.telegram_user_id, c.source, c.first_touch_at, c.is_recent + ), + agg as ( + select + source, + count(*)::int as cohort_size, + sum(case when is_recent then 1 else 0 end)::int as inbound_count, + (sum(case when bookings_14d >= 1 then 1 else 0 end)::numeric / count(*)) as fbc14, + (sum(case when bookings_60d >= 2 then 1 else 0 end)::numeric / count(*)) as rr60 + from booked + group by source + ), + channel_totals as ( + select + split_part(source, '_', 1) as channel, + sum(cohort_size)::numeric as total, + sum(case when source in ('unknown_legacy','unknown_unknown') then cohort_size else 0 end)::numeric as unk + from agg + group by 1 + ) + select + a.source, + a.inbound_count, + a.cohort_size, + a.fbc14, + a.rr60, + (a.fbc14 * a.rr60) as inbound_quality, + case + when ct.total > 0 then round(ct.unk / ct.total, 6) + else 0::numeric + end as unknown_share, + split_part(a.source, '_', 1) as channel, + case + when a.source in ('unknown_legacy','unknown_unknown') then 'legacy' + when exists ( + select 1 + from wavex_os.marketing_campaigns mc + where mc.campaign_slug = a.source + and mc.launched_at <= (select w_end from bounds) + and (mc.ended_at is null or mc.ended_at >= (select w_start from bounds)) + ) then 'inferred' + else 'captured' + end as confidence, + case when a.cohort_size >= 5 then 'ranked' else 'insufficient_data' end as bucket, + (select w_start from bounds) as window_start, + (select w_end from bounds) as window_end + from agg a + left join channel_totals ct + on split_part(a.source, '_', 1) = ct.channel + order by inbound_quality desc nulls last; +$$; + +create or replace function public.wavex_os_compute_inbound_quality( + p_window_end timestamptz default date_trunc('week', now() at time zone 'UTC') +) +returns table ( + source text, + inbound_count integer, + cohort_size integer, + fbc14 numeric, + rr60 numeric, + inbound_quality numeric, + unknown_share numeric, + channel text, + confidence text, + bucket text, + window_start timestamptz, + window_end timestamptz +) +language sql +stable +security definer +set search_path = '' +as $$ + select * from wavex_os.compute_inbound_quality(p_window_end); +$$; + +revoke all on function public.wavex_os_compute_inbound_quality(timestamptz) from public; +grant execute on function public.wavex_os_compute_inbound_quality(timestamptz) to service_role; + +comment on function wavex_os.compute_inbound_quality(timestamptz) is + 'Returns per-source inbound-quality rows for the 90d window ending at ' + 'p_window_end (default = start of current ISO week, UTC). Confidence is ' + 'derived from source identity: legacy sentinels → legacy; sources ' + 'matching an active marketing_campaigns row → inferred; otherwise → ' + 'captured. The Node writer (inbound-quality-sampler.ts) deletes any ' + 'prior kpi_snapshots row matching (kpi_name, metadata->>window_end) ' + 'before inserting, so re-runs are idempotent.'; + +commit; diff --git a/supabase/migrations/20260528000001_wavex_os_attribution_coverage_view.sql b/supabase/migrations/20260528000001_wavex_os_attribution_coverage_view.sql new file mode 100644 index 00000000..9c9f7c16 --- /dev/null +++ b/supabase/migrations/20260528000001_wavex_os_attribution_coverage_view.sql @@ -0,0 +1,37 @@ +-- WaveX OS CDO/ATTRIBUTE — attribution_coverage measurement view +-- Lineage: wavex_os.auth_events → wavex_os.v_attribution_coverage_7d +-- Owner: cdo_attribute +-- +-- KPI contract: utm_attribution_coverage +-- = share of signup_confirmed auth events (trailing 7d) with non-null utm_source +-- target: >= 0.80 (80%) +-- window: 7 days rolling +-- +-- Usage: +-- SELECT coverage_ratio FROM wavex_os.v_attribution_coverage_7d; +-- → value in [0.0, 1.0]; multiply by 100 for percentage +-- +-- Source table: wavex_os.auth_events +-- Populated by POST /api/auth-events (packages/wavex-os-server/src/routes/auth-events.ts) +-- Fired by packages/onboarding-ui/src/pages/Signup.tsx on magic-link confirmation +-- Denominator: all signup_confirmed rows in trailing 7d window (organic + attributed) +-- Numerator: subset where utm_source IS NOT NULL + +create or replace view wavex_os.v_attribution_coverage_7d as +select + count(*) filter (where utm_source is not null)::float + / nullif(count(*), 0) as coverage_ratio, + count(*) as total_signups_7d, + count(*) filter (where utm_source is not null) as attributed_signups_7d, + count(*) filter (where utm_source is null) as organic_signups_7d, + now() - interval '7 days' as window_start, + now() as window_end +from wavex_os.auth_events +where event_type = 'signup_confirmed' + and created_at > now() - interval '7 days'; + +comment on view wavex_os.v_attribution_coverage_7d is + 'CDO/ATTRIBUTE measurement view for utm_attribution_coverage KPI. ' + 'coverage_ratio = attributed_signups_7d / total_signups_7d. ' + 'Baseline: denominator is organic-only until first paid campaign runs. ' + 'Feed into mc_kpi_snapshots for kpiId=utm_attribution_coverage.'; diff --git a/supabase/migrations/20260528000002_wavex_os_reengagement_value_metrics.sql b/supabase/migrations/20260528000002_wavex_os_reengagement_value_metrics.sql new file mode 100644 index 00000000..e9a62b8c --- /dev/null +++ b/supabase/migrations/20260528000002_wavex_os_reengagement_value_metrics.sql @@ -0,0 +1,144 @@ +-- WAVAAAAA-213: extend professional_reengagement_metrics with post-nudge booking value +-- +-- Append-only: adds 4 new columns to the RPC return type. +-- Existing columns (nudges_sent, nudges_dry_run, reactivated, reactivation_rate) +-- are unchanged. +-- +-- New columns: +-- mean_post_nudge_booking_value avg(bookings.amount) for confirmed bookings +-- within p_window_hours of a real nudge send. +-- NULL when no real sends in window. +-- total_post_nudge_gmv sum of same. NULL when no real sends. +-- pro_aov_baseline_90d cohort-level average of per-pro avg booking +-- value in the 90d prior to their nudge send. +-- NULL when no real sends or no prior bookings. +-- aov_delta_pct (mean_post_nudge - baseline) / baseline. +-- NULL when baseline is NULL or zero. +-- +-- CRO/EXPANSION will not flip WAVEX_REENGAGEMENT_DRY_RUN=false until this +-- lands and the dashboard shows non-null value fields from the dry-run cohort. + +create or replace function wavex_os.professional_reengagement_metrics( + p_window_hours integer default 48, + p_since timestamptz default (now() - interval '30 days') +) +returns table ( + nudges_sent integer, + nudges_dry_run integer, + reactivated integer, + reactivation_rate numeric, + mean_post_nudge_booking_value numeric, + total_post_nudge_gmv numeric, + pro_aov_baseline_90d numeric, + aov_delta_pct numeric +) +language sql +stable +set search_path = '' +as $$ + with sent as ( + select n.* + from wavex_os.professional_reengagement_nudges n + where n.sent_at >= p_since + ), + real_sends as ( + select * from sent where dry_run = false + ), + reactivated as ( + select distinct s.id as nudge_id + from real_sends s + join wavex_os.telegram_events t + on t.user_id = s.user_id + and t.event_type = 'booking_confirmed' + and t.occurred_at > s.sent_at + and t.occurred_at <= (s.sent_at + make_interval(hours => p_window_hours)) + ), + -- Confirmed bookings created within the reactivation window after each real send. + -- Dry-run sends excluded — they have no expected downstream bookings. + post_nudge_bookings as ( + select bk.amount + from real_sends s + join public.bookings bk + on bk.user_id = s.user_id + and bk.booking_status in ('confirmed', 'completed') + and bk.created_at > s.sent_at + and bk.created_at <= (s.sent_at + make_interval(hours => p_window_hours)) + ), + -- Per-nudged-pro average booking value in the 90 days before their send. + -- Used as the pre-nudge AOV baseline to detect regression. + baseline_per_pro as ( + select + s.user_id, + avg(bk.amount) as pro_avg + from real_sends s + join public.bookings bk + on bk.user_id = s.user_id + and bk.booking_status in ('confirmed', 'completed') + and bk.created_at >= (s.sent_at - interval '90 days') + and bk.created_at < s.sent_at + group by s.user_id + ) + select + (select count(*)::int from sent where dry_run = false) as nudges_sent, + (select count(*)::int from sent where dry_run = true) as nudges_dry_run, + (select count(*)::int from reactivated) as reactivated, + -- reactivation_rate: 0 (not null) when no real sends, matching prior contract + case + when (select count(*) from sent where dry_run = false) = 0 + then 0::numeric + else round( + (select count(*)::numeric from reactivated) + / (select count(*)::numeric from sent where dry_run = false), + 4) + end as reactivation_rate, + -- Value fields: NULL (not 0) when no real sends — distinguishes "no data" + -- from "zero AOV". avg/sum of empty set return NULL naturally. + (select avg(amount) from post_nudge_bookings) as mean_post_nudge_booking_value, + (select sum(amount) from post_nudge_bookings) as total_post_nudge_gmv, + (select avg(pro_avg) from baseline_per_pro) as pro_aov_baseline_90d, + case + when (select avg(pro_avg) from baseline_per_pro) is null + or (select avg(pro_avg) from baseline_per_pro) = 0 + then null + else round( + ( + coalesce((select avg(amount) from post_nudge_bookings), 0) + - (select avg(pro_avg) from baseline_per_pro) + ) / (select avg(pro_avg) from baseline_per_pro), + 4) + end as aov_delta_pct; +$$; + +-- Replace the public wrapper to reflect the extended return type. +create or replace function public.wavex_os_professional_reengagement_metrics( + p_window_hours integer default 48, + p_since timestamptz default (now() - interval '30 days') +) +returns table ( + nudges_sent integer, + nudges_dry_run integer, + reactivated integer, + reactivation_rate numeric, + mean_post_nudge_booking_value numeric, + total_post_nudge_gmv numeric, + pro_aov_baseline_90d numeric, + aov_delta_pct numeric +) +language sql +security definer +set search_path = '' +as $$ + select * from wavex_os.professional_reengagement_metrics(p_window_hours, p_since); +$$; + +revoke all on function public.wavex_os_professional_reengagement_metrics(integer, timestamptz) from public; +grant execute on function public.wavex_os_professional_reengagement_metrics(integer, timestamptz) to service_role; + +comment on function wavex_os.professional_reengagement_metrics(integer, timestamptz) is + 'WAVAAAAA-63 nudge efficacy metrics. Original fields: nudges_sent (real), ' + 'nudges_dry_run, reactivated (booking within p_window_hours of a real send), ' + 'reactivation_rate. Extended by WAVAAAAA-213: mean_post_nudge_booking_value, ' + 'total_post_nudge_gmv (value of those bookings), pro_aov_baseline_90d ' + '(cohort avg booking value 90d pre-nudge), aov_delta_pct ' + '((post - baseline) / baseline). Value fields are NULL, not 0, when no real ' + 'sends exist — distinguishes no-data from zero-AOV.';