fix(waitlist): queue + retry failed signups instead of silently opening mailto (#165)

A signup that hit a network error, the 8s timeout or a 5xx was handed straight
to a `mailto:` composer. That path is lossy by design: it only works if the user
actually presses send, and if we keep reconciling the support inbox into Brevo
list 4 forever (AGE-61's hourly job). 20 of 21 signups were lost that way before
that reconciler existed, and Play's active base is ~100% on v0.4.10+ — so this
was current builds leaking, not just the ~436 stale sideloads.

Now:
- Failed-but-retryable signups are persisted on-device
  (`opencode.waitlist.pending.v1`, AsyncStorage) and retried on every app
  foreground (`app/_layout.tsx`) and on the Add Connection screen mount.
- 4xx stays non-retryable: the server will never accept that address, so we ask
  the user to fix it instead of queueing garbage forever.
- `mailto:` is now only ever opened by an explicit user tap ("Still not working?
  Email us instead"), shown after 3 failed attempts, or offered in an alert when
  device storage itself refuses the write — never as the silent default.
- The UI tells the truth: "Saved on this device — we'll finish signing you up as
  soon as you're back online" instead of implying it was sent.
- `WaitlistResult.fallback` -> `retryable`, `shouldFallbackToMailto` ->
  `isRetryableFailure`: the decision is about retry, not about mail.

Queue policy: dedupe by email, cap 5 entries, 30-day TTL, corrupt/foreign JSON
is discarded rather than replayed. Storage and the clock are injected so the
whole thing runs under `node --test` (16 new tests, incl. the acceptance case:
offline signup -> queued -> reconnect -> reaches the server, no mail client).

Also commits the AGE-61 measurement artifacts that were only ever local
(`distribution/waitlist-signup-path-coverage.md`, `scripts/play-version-share.mjs`)
and updates the doc's "current builds still leak" section, which this fixes.

Refs AGE-87, AGE-61.

Co-authored-by: engineer <engineer@macbookpro.lan>
This commit is contained in:
Den
2026-08-14 01:30:23 -07:00
committed by GitHub
parent 98233d351f
commit 2f81d34200
9 changed files with 698 additions and 57 deletions

View File

@@ -19,6 +19,7 @@ import * as notifications from "../src/lib/notifications"
import { addBreadcrumb, wrap } from "../src/lib/sentry" import { addBreadcrumb, wrap } from "../src/lib/sentry"
import { loadTelemetryConsent, setTelemetryConsent } from "../src/lib/telemetry" import { loadTelemetryConsent, setTelemetryConsent } from "../src/lib/telemetry"
import { initAnalytics, trackAppOpened } from "../src/lib/analytics" import { initAnalytics, trackAppOpened } from "../src/lib/analytics"
import { flushPendingSignups } from "../src/lib/waitlist-queue-storage"
const queryClient = new QueryClient() const queryClient = new QueryClient()
@@ -95,6 +96,30 @@ function RootLayout() {
return () => sub.remove() return () => sub.remove()
}, []) }, [])
// Retry any waitlist signup that couldn't reach the server when the user
// tapped Join (AGE-87). Runs at cold start and on every foreground, which is
// the cheapest reliable proxy for "connectivity may have come back" — it is a
// no-op (single storage read, no network) when the queue is empty, and it
// replaces the old silent mailto: fallback that lost 20 of 21 signups.
useEffect(() => {
const flush = () => {
void flushPendingSignups()
.then((outcome) => {
if (outcome.synced.length > 0) {
addBreadcrumb({ category: "waitlist", message: `retried ${outcome.synced.length} queued signup(s)` })
}
})
.catch(() => {
// Best effort: the entry stays queued for the next foreground.
})
}
flush()
const sub = AppState.addEventListener("change", (next) => {
if (next === "active") flush()
})
return () => sub.remove()
}, [])
// Connect/disconnect SSE and load catalog when client changes // Connect/disconnect SSE and load catalog when client changes
useEffect(() => { useEffect(() => {
if (client && !sseStarted.current) { if (client && !sseStarted.current) {

View File

@@ -1,4 +1,4 @@
import { useState } from "react" import { useEffect, useState } from "react"
import { import {
View, View,
Text, Text,
@@ -21,7 +21,8 @@ import { captureDiagnostic } from "../../src/lib/sentry"
import { parseUrl } from "../../src/lib/diagnostics-classify" import { parseUrl } from "../../src/lib/diagnostics-classify"
import { buildAuth } from "../../src/lib/auth" import { buildAuth } from "../../src/lib/auth"
import { AnalyticsEvent, track } from "../../src/lib/analytics" import { AnalyticsEvent, track } from "../../src/lib/analytics"
import { submitWaitlistSignup, buildWaitlistMailtoUrl } from "../../src/lib/waitlist" import { submitWaitlistSignup, buildWaitlistMailtoUrl, needsManualEscapeHatch, type QueuedSignup } from "../../src/lib/waitlist"
import { flushPendingSignups, queuePendingSignup, readPendingSignups, dropPendingSignup } from "../../src/lib/waitlist-queue-storage"
export default function AddConnectionScreen() { export default function AddConnectionScreen() {
const colorScheme = useColorScheme() const colorScheme = useColorScheme()
@@ -41,7 +42,34 @@ export default function AddConnectionScreen() {
const [password, setPassword] = useState("") const [password, setPassword] = useState("")
const [isConnecting, setIsConnecting] = useState(false) const [isConnecting, setIsConnecting] = useState(false)
const [waitlistEmail, setWaitlistEmail] = useState("") const [waitlistEmail, setWaitlistEmail] = useState("")
const [waitlistState, setWaitlistState] = useState<"idle" | "submitting" | "joined">("idle") // "queued" = the POST failed but the signup is persisted on-device and will
// be retried on the next foreground/connectivity (AGE-87). It is NOT "sent".
const [waitlistState, setWaitlistState] = useState<"idle" | "submitting" | "joined" | "queued">("idle")
const [pendingSignup, setPendingSignup] = useState<QueuedSignup | null>(null)
// Retry anything left over from a previous session as soon as this screen
// opens (the root layout also flushes on every foreground), then reflect the
// real state back to the user instead of pretending nothing is pending.
useEffect(() => {
let cancelled = false
void (async () => {
const outcome = await flushPendingSignups().catch(() => null)
if (cancelled) return
const pending = outcome ? outcome.pending : await readPendingSignups().catch(() => [])
if (cancelled) return
if (outcome && outcome.synced.length > 0 && pending.length === 0) {
setWaitlistState("joined")
return
}
if (pending.length > 0) {
setPendingSignup(pending[pending.length - 1])
setWaitlistState((current) => (current === "idle" ? "queued" : current))
}
})()
return () => {
cancelled = true
}
}, [])
const buildUrl = () => { const buildUrl = () => {
if (mode === "advanced") return url.trim() if (mode === "advanced") return url.trim()
@@ -213,25 +241,70 @@ export default function AddConnectionScreen() {
const handleJoinWaitlist = async () => { const handleJoinWaitlist = async () => {
if (waitlistState === "submitting") return if (waitlistState === "submitting") return
const attemptedEmail = waitlistEmail
setWaitlistState("submitting") setWaitlistState("submitting")
const result = await submitWaitlistSignup(waitlistEmail) const result = await submitWaitlistSignup(attemptedEmail)
if (result.ok) { if (result.ok) {
// Clear any earlier queued attempt for the same address so the flush
// doesn't re-post it.
void dropPendingSignup(result.email)
setPendingSignup(null)
setWaitlistState("joined") setWaitlistState("joined")
return return
} }
setWaitlistState("idle")
if (result.fallback) { if (!result.retryable) {
// API unreachable/broken: fall back to the pre-#87 mailto path so the // The server rejected this address; queueing it would retry forever.
// signup still reaches the support inbox instead of being lost. setWaitlistState(pendingSignup ? "queued" : "idle")
try {
await Linking.openURL(buildWaitlistMailtoUrl(result.email))
} catch {
// No mail app either — tell the user instead of failing silently.
Alert.alert(t("connection.add.waitlist.alertTitle"), t("connection.add.waitlist.fallbackMessage"))
}
} else {
Alert.alert(t("connection.add.waitlist.alertTitle"), result.error) Alert.alert(t("connection.add.waitlist.alertTitle"), result.error)
return
} }
// Offline / timeout / 5xx: persist and retry later instead of dumping the
// user into a mail composer they may never send (AGE-87).
const entry = await queuePendingSignup(result.email, result.error)
if (entry) {
setPendingSignup(entry)
setWaitlistState("queued")
return
}
// Storage refused the write — we cannot promise to finish this later, so
// offer the manual email path explicitly rather than claiming success.
setWaitlistState("idle")
Alert.alert(t("connection.add.waitlist.alertTitle"), t("connection.add.waitlist.queueFailedMessage"), [
{ text: t("common.cancel"), style: "cancel" },
{ text: t("connection.add.waitlist.emailUsButton"), onPress: () => void openWaitlistMailto(result.email) },
])
}
// Last-resort, user-initiated only. Never opened automatically.
const openWaitlistMailto = async (email: string) => {
try {
await Linking.openURL(buildWaitlistMailtoUrl(email))
} catch {
Alert.alert(t("connection.add.waitlist.alertTitle"), t("connection.add.waitlist.noMailAppMessage"))
}
}
// Explicit "Retry" from the queued state — same code path the foreground
// flush uses, so there is only one retry implementation.
const handleRetryQueued = async () => {
setWaitlistState("submitting")
const outcome = await flushPendingSignups().catch(() => null)
if (outcome && outcome.pending.length === 0 && outcome.synced.length > 0) {
setPendingSignup(null)
setWaitlistState("joined")
return
}
if (outcome && outcome.pending.length === 0) {
// Nothing left pending and nothing synced: the address was rejected.
setPendingSignup(null)
setWaitlistState("idle")
return
}
if (outcome) setPendingSignup(outcome.pending[outcome.pending.length - 1])
setWaitlistState("queued")
} }
// Quick connect mode - simplified // Quick connect mode - simplified
@@ -376,6 +449,27 @@ export default function AddConnectionScreen() {
{t("connection.add.waitlist.successText")} {t("connection.add.waitlist.successText")}
</Text> </Text>
</View> </View>
) : waitlistState === "queued" ? (
<View testID="waitlist-queued">
<View style={styles.waitlistSuccess}>
<Ionicons name="time-outline" size={20} color="#f59e0b" />
<Text style={[styles.waitlistSuccessText, isDark && styles.textDark]}>
{t("connection.add.waitlist.queuedText")}
</Text>
</View>
{needsManualEscapeHatch(pendingSignup) && (
<TouchableOpacity
style={styles.waitlistEscapeHatch}
onPress={() => void openWaitlistMailto(pendingSignup?.email ?? waitlistEmail)}
testID="waitlist-email-us"
>
<Text style={styles.waitlistEscapeHatchText}>{t("connection.add.waitlist.emailUsLink")}</Text>
</TouchableOpacity>
)}
<TouchableOpacity style={styles.waitlistEscapeHatch} onPress={() => void handleRetryQueued()} testID="waitlist-retry">
<Text style={styles.waitlistEscapeHatchText}>{t("common.retry")}</Text>
</TouchableOpacity>
</View>
) : ( ) : (
<> <>
<TextInput <TextInput
@@ -834,4 +928,13 @@ const styles = StyleSheet.create({
color: "#0a0a0a", color: "#0a0a0a",
lineHeight: 20, lineHeight: 20,
}, },
waitlistEscapeHatch: {
marginTop: 8,
paddingVertical: 4,
},
waitlistEscapeHatchText: {
fontSize: 13,
fontWeight: "600",
color: "#6366f1",
},
}) })

View File

@@ -29,10 +29,14 @@ returns 0 results) and the app is not on IzzyOnDroid, so those channels contribu
the owner manually re-downloads. The hourly reconciler the owner manually re-downloads. The hourly reconciler
(`VibeBrowserProductPage/.github/workflows/waitlist-mailto-reconcile.yml`) is what keeps those (`VibeBrowserProductPage/.github/workflows/waitlist-mailto-reconcile.yml`) is what keeps those
signups from being lost, and it is not a temporary measure. signups from being lost, and it is not a temporary measure.
3. **Stale builds are not the only source of mailto signups.** In current builds the fallback also 3. **Stale builds are not the only source of mailto signups** — they are, as of AGE-87, the only
fires on network error, timeout (8s) and 5xx (`src/lib/waitlist.ts:shouldFallbackToMailto`). *remaining* one. Until v0.4.12 the fallback also fired on network error, timeout (8s) and 5xx,
A user on v0.4.12 with flaky mobile data takes the same lossy path. Any plan that assumes so a user on a current build with flaky mobile data took the same lossy path. That path is gone:
"ship an update and the leak closes" is wrong. a failed signup is now persisted on-device (`opencode.waitlist.pending.v1`, AsyncStorage) and
retried on every app foreground (`src/lib/waitlist.ts` queue section, flushed from
`app/_layout.tsx`). `mailto:` is only ever opened by an explicit user tap after repeated
retry failures. Expect the reconciler's `synced_count` to trend toward the sideload cohort only.
Any plan that assumes "ship an update and the leak closes" is still wrong for those ~436 devices.
## Method / reproducing ## Method / reproducing

View File

@@ -181,7 +181,11 @@
"successText": "You're on the list — we'll email you when OpenCode Connect is ready.", "successText": "You're on the list — we'll email you when OpenCode Connect is ready.",
"joinButton": "Join Waitlist", "joinButton": "Join Waitlist",
"alertTitle": "Join Waitlist", "alertTitle": "Join Waitlist",
"fallbackMessage": "Could not reach the signup service or open an email app. Please email support@agentlabs.cc with subject \"OpenCode Connect Waitlist\"." "queuedText": "Saved on this device — we'll finish signing you up as soon as you're back online. You don't have to do anything.",
"emailUsLink": "Still not working? Email us instead",
"emailUsButton": "Email us",
"queueFailedMessage": "We couldn't reach the signup service or save your request on this device. You can email us instead and we'll add you by hand.",
"noMailAppMessage": "No email app is available. Please email support@agentlabs.cc with subject \"OpenCode Connect Waitlist\"."
}, },
"advanced": { "advanced": {
"backToQuick": "Simple mode", "backToQuick": "Simple mode",

View File

@@ -181,7 +181,11 @@
"successText": "您已加入候补名单 — OpenCode Connect 准备就绪时我们会通过邮件通知您。", "successText": "您已加入候补名单 — OpenCode Connect 准备就绪时我们会通过邮件通知您。",
"joinButton": "加入候补名单", "joinButton": "加入候补名单",
"alertTitle": "加入候补名单", "alertTitle": "加入候补名单",
"fallbackMessage": "无法连接注册服务或打开邮件应用。请发送邮件至 support@agentlabs.cc,主题为 \"OpenCode Connect Waitlist\"。" "queuedText": "已保存在此设备上 — 联网后我们会自动完成注册,你无需再做任何操作。",
"emailUsLink": "仍然无法注册?改为给我们发邮件",
"emailUsButton": "发送邮件",
"queueFailedMessage": "无法连接注册服务,也无法在此设备上保存你的请求。你可以改为给我们发邮件,我们会手动将你加入名单。",
"noMailAppMessage": "没有可用的邮件应用。请发送邮件至 support@agentlabs.cc,主题为 \"OpenCode Connect Waitlist\"。"
}, },
"advanced": { "advanced": {
"backToQuick": "简易模式", "backToQuick": "简易模式",

View File

@@ -0,0 +1,35 @@
// Production wiring for the waitlist retry queue (AGE-87).
//
// Split out from waitlist-queue.ts so the queue logic stays importable by
// `node --test` (no react-native / AsyncStorage native module). This file is
// the only place that touches device storage.
//
// AsyncStorage (not SecureStore) on purpose: a pending waitlist email is not a
// credential, and this queue must survive a keychain that refuses to unlock —
// the whole point is that the signup is never silently lost.
import AsyncStorage from "@react-native-async-storage/async-storage"
import { flushQueue, loadQueue, enqueueSignup, removeFromQueue, type FlushOutcome, type QueuedSignup, type QueueStorage } from "./waitlist"
const storage: QueueStorage = {
getItem: (key) => AsyncStorage.getItem(key),
setItem: (key, value) => AsyncStorage.setItem(key, value),
removeItem: (key) => AsyncStorage.removeItem(key),
}
export function queuePendingSignup(email: string, error?: string): Promise<QueuedSignup | null> {
return enqueueSignup(storage, email, { error })
}
export function readPendingSignups(): Promise<QueuedSignup[]> {
return loadQueue(storage)
}
export function dropPendingSignup(email: string): Promise<void> {
return removeFromQueue(storage, email)
}
/** Best-effort retry of every pending signup. Safe to call on every foreground. */
export function flushPendingSignups(): Promise<FlushOutcome> {
return flushQueue(storage)
}

View File

@@ -0,0 +1,244 @@
import { test } from "node:test"
import assert from "node:assert/strict"
import {
WAITLIST_QUEUE_KEY,
WAITLIST_QUEUE_MAX,
WAITLIST_QUEUE_TTL_MS,
enqueueSignup,
flushQueue,
loadQueue,
needsManualEscapeHatch,
pruneQueue,
removeFromQueue,
type QueueStorage,
type QueuedSignup,
type WaitlistResult,
} from "./waitlist.ts"
// In-memory stand-in for AsyncStorage. `fail` makes every write throw, which is
// how a device with full/locked storage behaves.
function memoryStorage(initial?: string, opts: { failWrites?: boolean } = {}) {
const map = new Map<string, string>()
if (initial !== undefined) map.set(WAITLIST_QUEUE_KEY, initial)
const storage: QueueStorage & { map: Map<string, string> } = {
map,
async getItem(key) {
return map.get(key) ?? null
},
async setItem(key, value) {
if (opts.failWrites) throw new Error("storage full")
map.set(key, value)
},
async removeItem(key) {
if (opts.failWrites) throw new Error("storage full")
map.delete(key)
},
}
return storage
}
function stored(storage: { map: Map<string, string> }): QueuedSignup[] {
const raw = storage.map.get(WAITLIST_QUEUE_KEY)
return raw ? (JSON.parse(raw) as QueuedSignup[]) : []
}
const ok = (email: string): WaitlistResult => ({ ok: true, email })
const offline = (email: string): WaitlistResult => ({ ok: false, email, retryable: true, error: "Network request failed" })
const rejected = (email: string): WaitlistResult => ({ ok: false, email, retryable: false, error: "Enter a valid email address." })
// --- enqueue ---
test("enqueue persists a normalized signup with attempt 1", async () => {
const storage = memoryStorage()
const entry = await enqueueSignup(storage, " Dev@Example.COM ", { now: 1_000, error: "Network request failed" })
assert.deepEqual(entry, {
email: "dev@example.com",
queuedAt: 1_000,
attempts: 1,
lastAttemptAt: 1_000,
lastError: "Network request failed",
})
assert.deepEqual(stored(storage), [entry])
})
test("enqueue dedupes by email and bumps attempts instead of growing the queue", async () => {
const storage = memoryStorage()
await enqueueSignup(storage, "dev@example.com", { now: 1_000 })
const second = await enqueueSignup(storage, "DEV@example.com", { now: 2_000 })
assert.equal(stored(storage).length, 1)
assert.equal(second?.attempts, 2)
assert.equal(second?.queuedAt, 1_000, "original queue time is preserved")
assert.equal(second?.lastAttemptAt, 2_000)
})
test("enqueue caps the queue at WAITLIST_QUEUE_MAX, keeping the newest", async () => {
const storage = memoryStorage()
for (let i = 0; i < WAITLIST_QUEUE_MAX + 3; i++) {
await enqueueSignup(storage, `user${i}@example.com`, { now: 1_000 + i })
}
const queue = stored(storage)
assert.equal(queue.length, WAITLIST_QUEUE_MAX)
assert.equal(queue[queue.length - 1].email, `user${WAITLIST_QUEUE_MAX + 2}@example.com`)
})
test("enqueue refuses an invalid email — nothing is stored, caller must not claim success", async () => {
const storage = memoryStorage()
assert.equal(await enqueueSignup(storage, "not-an-email"), null)
assert.equal(storage.map.size, 0)
})
test("enqueue returns null when storage refuses the write (never lie about saving)", async () => {
const storage = memoryStorage(undefined, { failWrites: true })
assert.equal(await enqueueSignup(storage, "dev@example.com", { now: 1 }), null)
})
// --- load resilience: a corrupt blob must not brick the screen ---
test("load tolerates missing, corrupt, non-array and foreign-shaped payloads", async () => {
assert.deepEqual(await loadQueue(memoryStorage()), [])
assert.deepEqual(await loadQueue(memoryStorage("{not json")), [])
assert.deepEqual(await loadQueue(memoryStorage('{"email":"a@b.co"}')), [])
assert.deepEqual(await loadQueue(memoryStorage('[{"nope":1},null,3]')), [])
assert.deepEqual(
await loadQueue(memoryStorage('[{"email":"NOT normalized","queuedAt":1,"attempts":1}]')),
[],
"entries that were never normalized are dropped rather than replayed as garbage",
)
})
test("load backfills lastAttemptAt for entries written before that field existed", async () => {
const storage = memoryStorage('[{"email":"dev@example.com","queuedAt":42,"attempts":1}]')
assert.deepEqual(await loadQueue(storage), [
{ email: "dev@example.com", queuedAt: 42, attempts: 1, lastAttemptAt: 42 },
])
})
test("load survives a storage read that throws", async () => {
const storage: QueueStorage = {
async getItem() {
throw new Error("AsyncStorage unavailable")
},
async setItem() {},
async removeItem() {},
}
assert.deepEqual(await loadQueue(storage), [])
})
// --- TTL ---
test("entries older than the TTL are pruned", () => {
const fresh: QueuedSignup = { email: "a@b.co", queuedAt: 1_000, attempts: 1, lastAttemptAt: 1_000 }
const stale: QueuedSignup = { email: "c@d.co", queuedAt: 0, attempts: 9, lastAttemptAt: 0 }
assert.deepEqual(pruneQueue([fresh, stale], WAITLIST_QUEUE_TTL_MS + 500), [fresh])
})
// --- flush: the AGE-87 acceptance test ---
test("offline signup reaches the server on the next flush, with no mail client involved", async () => {
const storage = memoryStorage()
// 1. User taps "Join Waitlist" on a plane: the POST fails, we queue it.
const failure = offline("dev@example.com")
assert.equal(failure.ok, false)
const queued = await enqueueSignup(storage, "dev@example.com", { now: 1_000, error: failure.error })
assert.equal(queued?.attempts, 1)
// 2. Still offline on the next foreground: kept, attempts bumped.
const attempted: string[] = []
const stillOffline = await flushQueue(storage, {
now: 2_000,
submit: async (email) => {
attempted.push(email)
return offline(email)
},
})
assert.deepEqual(stillOffline.synced, [])
assert.deepEqual(stillOffline.pending.map((e) => e.attempts), [2])
assert.deepEqual(stored(storage).map((e) => e.email), ["dev@example.com"])
// 3. Device reconnects: the queued signup lands in Brevo list 4 and the
// queue empties. No mailto: URL was ever built.
const reconnected = await flushQueue(storage, {
now: 3_000,
submit: async (email) => {
attempted.push(email)
return ok(email)
},
})
assert.deepEqual(reconnected.synced, ["dev@example.com"])
assert.deepEqual(reconnected.pending, [])
assert.equal(storage.map.has(WAITLIST_QUEUE_KEY), false, "queue key is removed once empty")
assert.deepEqual(attempted, ["dev@example.com", "dev@example.com"])
})
test("flush drops entries the server permanently rejects (4xx) instead of retrying forever", async () => {
const storage = memoryStorage()
await enqueueSignup(storage, "dev@example.com", { now: 1 })
const outcome = await flushQueue(storage, { now: 2, submit: async (email) => rejected(email) })
assert.deepEqual(outcome.rejected, ["dev@example.com"])
assert.deepEqual(outcome.pending, [])
assert.deepEqual(stored(storage), [])
})
test("flush keeps the other entries when one submit throws", async () => {
const storage = memoryStorage()
await enqueueSignup(storage, "a@example.com", { now: 1 })
await enqueueSignup(storage, "b@example.com", { now: 2 })
const outcome = await flushQueue(storage, {
now: 3,
submit: async (email) => {
if (email === "a@example.com") throw new TypeError("boom")
return ok(email)
},
})
assert.deepEqual(outcome.synced, ["b@example.com"])
assert.deepEqual(outcome.pending.map((e) => e.email), ["a@example.com"])
assert.match(outcome.pending[0].lastError ?? "", /boom/)
})
test("flush prunes expired entries without submitting them", async () => {
const storage = memoryStorage()
await enqueueSignup(storage, "old@example.com", { now: 0 })
let called = 0
const outcome = await flushQueue(storage, {
now: WAITLIST_QUEUE_TTL_MS + 1,
submit: async (email) => {
called++
return ok(email)
},
})
assert.equal(called, 0)
assert.deepEqual(outcome, { synced: [], rejected: [], pending: [] })
assert.deepEqual(stored(storage), [])
})
test("flush on an empty queue is a no-op", async () => {
const storage = memoryStorage()
const outcome = await flushQueue(storage, { submit: async () => assert.fail("must not submit") })
assert.deepEqual(outcome, { synced: [], rejected: [], pending: [] })
})
// --- manual escape hatch policy ---
test("the manual email escape hatch appears only after repeated failures", async () => {
const storage = memoryStorage()
let entry = await enqueueSignup(storage, "dev@example.com", { now: 1 })
assert.equal(needsManualEscapeHatch(entry), false)
entry = await enqueueSignup(storage, "dev@example.com", { now: 2 })
assert.equal(needsManualEscapeHatch(entry), false)
entry = await enqueueSignup(storage, "dev@example.com", { now: 3 })
assert.equal(needsManualEscapeHatch(entry), true, "3rd failed attempt -> offer 'still not working? email us'")
assert.equal(needsManualEscapeHatch(null), false)
})
// --- explicit removal ---
test("removeFromQueue drops just that email", async () => {
const storage = memoryStorage()
await enqueueSignup(storage, "a@example.com", { now: 1 })
await enqueueSignup(storage, "b@example.com", { now: 2 })
await removeFromQueue(storage, "a@example.com")
assert.deepEqual(stored(storage).map((e) => e.email), ["b@example.com"])
await removeFromQueue(storage, "missing@example.com") // no-op, no throw
assert.deepEqual(stored(storage).map((e) => e.email), ["b@example.com"])
})

View File

@@ -6,7 +6,7 @@ import {
normalizeWaitlistEmail, normalizeWaitlistEmail,
buildWaitlistPayload, buildWaitlistPayload,
buildWaitlistMailtoUrl, buildWaitlistMailtoUrl,
shouldFallbackToMailto, isRetryableFailure,
submitWaitlistSignup, submitWaitlistSignup,
} from "./waitlist.ts" } from "./waitlist.ts"
@@ -34,32 +34,32 @@ test("payload tags the signup with the opencode-connect source", () => {
}) })
}) })
// --- mailto fallback URL: byte-compatible with the pre-#87 behavior --- // --- mailto URL: last-resort escape hatch only (AGE-87), byte-compatible ---
test("mailto fallback preserves subject and embeds the email", () => { test("mailto escape hatch preserves subject and embeds the email", () => {
const url = buildWaitlistMailtoUrl("dev@example.com") const url = buildWaitlistMailtoUrl("dev@example.com")
assert.ok(url.startsWith("mailto:support@agentlabs.cc?")) assert.ok(url.startsWith("mailto:support@agentlabs.cc?"))
assert.ok(url.includes("subject=OpenCode%20Connect%20Waitlist")) assert.ok(url.includes("subject=OpenCode%20Connect%20Waitlist"))
assert.ok(url.includes(encodeURIComponent("Email: dev@example.com"))) assert.ok(url.includes(encodeURIComponent("Email: dev@example.com")))
}) })
test("mailto fallback works without an email", () => { test("mailto escape hatch works without an email", () => {
const url = buildWaitlistMailtoUrl("") const url = buildWaitlistMailtoUrl("")
assert.ok(url.includes("body=Sign%20me%20up!")) assert.ok(url.includes("body=Sign%20me%20up!"))
assert.ok(!url.includes("Email")) assert.ok(!url.includes("Email"))
}) })
// --- fallback decision --- // --- retry decision ---
// 502 named explicitly: the server returns it when Brevo itself fails, and the // 502 named explicitly: the server returns it when Brevo itself fails, and the
// signup must survive that via mailto. // signup must survive that by being queued and retried (AGE-87), not mailed.
test("fallback: transport failures and 5xx (incl. 502 Brevo failure) -> mailto, 4xx -> fix input", () => { test("retryable: transport failures and 5xx (incl. 502 Brevo failure) -> retry, 4xx -> fix input", () => {
assert.equal(shouldFallbackToMailto({ kind: "network-error" }), true) assert.equal(isRetryableFailure({ kind: "network-error" }), true)
assert.equal(shouldFallbackToMailto({ kind: "http", status: 500 }), true) assert.equal(isRetryableFailure({ kind: "http", status: 500 }), true)
assert.equal(shouldFallbackToMailto({ kind: "http", status: 502 }), true) assert.equal(isRetryableFailure({ kind: "http", status: 502 }), true)
assert.equal(shouldFallbackToMailto({ kind: "http", status: 503 }), true) assert.equal(isRetryableFailure({ kind: "http", status: 503 }), true)
assert.equal(shouldFallbackToMailto({ kind: "http", status: 400 }), false) assert.equal(isRetryableFailure({ kind: "http", status: 400 }), false)
assert.equal(shouldFallbackToMailto({ kind: "http", status: 429 }), false) assert.equal(isRetryableFailure({ kind: "http", status: 429 }), false)
}) })
// --- submitWaitlistSignup with injected fetch --- // --- submitWaitlistSignup with injected fetch ---
@@ -89,37 +89,37 @@ test("submit rejects an invalid email locally without hitting the network", asyn
const calls: FetchCall[] = [] const calls: FetchCall[] = []
const result = await submitWaitlistSignup("nope", { fetchFn: fakeFetch({ ok: true, status: 200 }, calls) }) const result = await submitWaitlistSignup("nope", { fetchFn: fakeFetch({ ok: true, status: 200 }, calls) })
assert.equal(calls.length, 0) assert.equal(calls.length, 0)
assert.deepEqual(result, { ok: false, email: "nope", fallback: false, error: "Enter a valid email address." }) assert.deepEqual(result, { ok: false, email: "nope", retryable: false, error: "Enter a valid email address." })
}) })
test("submit surfaces the server's 400 message without falling back to mailto", async () => { test("submit surfaces the server's 400 message and marks it non-retryable", async () => {
const result = await submitWaitlistSignup("dev@example.com", { const result = await submitWaitlistSignup("dev@example.com", {
fetchFn: fakeFetch({ ok: false, status: 400, body: { error: "Enter a valid email address." } }), fetchFn: fakeFetch({ ok: false, status: 400, body: { error: "Enter a valid email address." } }),
}) })
assert.deepEqual(result, { ok: false, email: "dev@example.com", fallback: false, error: "Enter a valid email address." }) assert.deepEqual(result, { ok: false, email: "dev@example.com", retryable: false, error: "Enter a valid email address." })
}) })
test("submit falls back to mailto on 5xx (server broken, keep the signup alive)", async () => { test("submit marks 5xx retryable (server broken, keep the signup alive)", async () => {
const result = await submitWaitlistSignup("dev@example.com", { const result = await submitWaitlistSignup("dev@example.com", {
fetchFn: fakeFetch({ ok: false, status: 503, body: { error: "The waitlist is temporarily unavailable. Please try again later." } }), fetchFn: fakeFetch({ ok: false, status: 503, body: { error: "The waitlist is temporarily unavailable. Please try again later." } }),
}) })
assert.equal(result.ok, false) assert.equal(result.ok, false)
if (!result.ok) { if (!result.ok) {
assert.equal(result.fallback, true) assert.equal(result.retryable, true)
assert.equal(result.error, "The waitlist is temporarily unavailable. Please try again later.") assert.equal(result.error, "The waitlist is temporarily unavailable. Please try again later.")
} }
}) })
test("submit falls back to mailto when fetch rejects (offline)", async () => { test("submit marks a rejected fetch retryable (offline)", async () => {
const result = await submitWaitlistSignup("dev@example.com", { const result = await submitWaitlistSignup("dev@example.com", {
fetchFn: async () => { fetchFn: async () => {
throw new TypeError("Network request failed") throw new TypeError("Network request failed")
}, },
}) })
assert.deepEqual(result, { ok: false, email: "dev@example.com", fallback: true, error: "Network request failed" }) assert.deepEqual(result, { ok: false, email: "dev@example.com", retryable: true, error: "Network request failed" })
}) })
test("submit aborts after timeoutMs and falls back to mailto", async () => { test("submit aborts after timeoutMs and marks it retryable", async () => {
const result = await submitWaitlistSignup("dev@example.com", { const result = await submitWaitlistSignup("dev@example.com", {
timeoutMs: 20, timeoutMs: 20,
fetchFn: (_url, init) => fetchFn: (_url, init) =>
@@ -131,7 +131,7 @@ test("submit aborts after timeoutMs and falls back to mailto", async () => {
}) })
}), }),
}) })
assert.deepEqual(result, { ok: false, email: "dev@example.com", fallback: true, error: "timeout after 20ms" }) assert.deepEqual(result, { ok: false, email: "dev@example.com", retryable: true, error: "timeout after 20ms" })
}) })
test("submit tolerates a non-JSON error body", async () => { test("submit tolerates a non-JSON error body", async () => {
@@ -144,5 +144,5 @@ test("submit tolerates a non-JSON error body", async () => {
}, },
}), }),
}) })
assert.deepEqual(result, { ok: false, email: "dev@example.com", fallback: true, error: "Signup failed (HTTP 502)." }) assert.deepEqual(result, { ok: false, email: "dev@example.com", retryable: true, error: "Signup failed (HTTP 502)." })
}) })

View File

@@ -31,7 +31,12 @@ export function buildWaitlistPayload(email: string): { email: string; source: st
return { email, source: WAITLIST_SOURCE } return { email, source: WAITLIST_SOURCE }
} }
/** The pre-#87 mailto path, kept as the fallback when the API is unreachable. */ /**
* Last-resort, USER-INITIATED escape hatch (AGE-87). Never open this
* automatically: a failed signup is queued locally and retried
* (waitlist-queue.ts). This URL is only offered after retries keep failing,
* behind an explicit "still not working? email us" tap.
*/
export function buildWaitlistMailtoUrl(email: string): string { export function buildWaitlistMailtoUrl(email: string): string {
const subject = encodeURIComponent("OpenCode Connect Waitlist") const subject = encodeURIComponent("OpenCode Connect Waitlist")
const body = encodeURIComponent(email ? `Sign me up!\n\nEmail: ${email}` : "Sign me up!") const body = encodeURIComponent(email ? `Sign me up!\n\nEmail: ${email}` : "Sign me up!")
@@ -41,19 +46,19 @@ export function buildWaitlistMailtoUrl(email: string): string {
export type WaitlistResult = export type WaitlistResult =
/** Signup persisted server-side. */ /** Signup persisted server-side. */
| { ok: true; email: string } | { ok: true; email: string }
/** Signup not persisted. `fallback` decides the UX: true -> open the /** Signup not persisted. `retryable` decides the UX: true -> the same
* mailto fallback so the signup still reaches the support inbox; * request can succeed later, so queue it and retry on reconnect;
* false -> the input (or server validation) is wrong, ask the user to fix * false -> the input (or server validation) is wrong, ask the user to fix
* their email instead of mailing garbage. */ * their email instead of queueing garbage forever. */
| { ok: false; email: string; fallback: boolean; error: string } | { ok: false; email: string; retryable: boolean; error: string }
/** /**
* Fallback decision, isolated for testability: * Retry decision, isolated for testability:
* - transport failure (offline, DNS, timeout) -> mailto keeps the signup alive * - transport failure (offline, DNS, timeout) -> retry when connectivity returns
* - 5xx -> server broken through no fault of the user's -> mailto * - 5xx -> server broken through no fault of the user's -> retry
* - 4xx -> the server rejected this email; mailing it wouldn't help * - 4xx -> the server rejected this email; repeating it wouldn't help
*/ */
export function shouldFallbackToMailto(outcome: { kind: "network-error" } | { kind: "http"; status: number }): boolean { export function isRetryableFailure(outcome: { kind: "network-error" } | { kind: "http"; status: number }): boolean {
if (outcome.kind === "network-error") return true if (outcome.kind === "network-error") return true
return outcome.status >= 500 return outcome.status >= 500
} }
@@ -78,7 +83,7 @@ function serverError(body: unknown): string | undefined {
export async function submitWaitlistSignup(rawEmail: string, deps: WaitlistDeps = {}): Promise<WaitlistResult> { export async function submitWaitlistSignup(rawEmail: string, deps: WaitlistDeps = {}): Promise<WaitlistResult> {
const email = normalizeWaitlistEmail(rawEmail) const email = normalizeWaitlistEmail(rawEmail)
if (email === null) { if (email === null) {
return { ok: false, email: rawEmail.trim(), fallback: false, error: "Enter a valid email address." } return { ok: false, email: rawEmail.trim(), retryable: false, error: "Enter a valid email address." }
} }
const fetchFn = deps.fetchFn ?? (fetch as unknown as FetchLike) const fetchFn = deps.fetchFn ?? (fetch as unknown as FetchLike)
@@ -99,7 +104,7 @@ export async function submitWaitlistSignup(rawEmail: string, deps: WaitlistDeps
return { return {
ok: false, ok: false,
email, email,
fallback: shouldFallbackToMailto({ kind: "http", status: res.status }), retryable: isRetryableFailure({ kind: "http", status: res.status }),
error: message || `Signup failed (HTTP ${res.status}).`, error: message || `Signup failed (HTTP ${res.status}).`,
} }
} catch (error: unknown) { } catch (error: unknown) {
@@ -108,10 +113,227 @@ export async function submitWaitlistSignup(rawEmail: string, deps: WaitlistDeps
return { return {
ok: false, ok: false,
email, email,
fallback: shouldFallbackToMailto({ kind: "network-error" }), retryable: isRetryableFailure({ kind: "network-error" }),
error: aborted ? `timeout after ${timeoutMs}ms` : err?.message || String(error), error: aborted ? `timeout after ${timeoutMs}ms` : err?.message || String(error),
} }
} finally { } finally {
clearTimeout(timer) clearTimeout(timer)
} }
} }
// ---------------------------------------------------------------------------
// Durable retry queue (AGE-87)
// ---------------------------------------------------------------------------
//
// Before this, a signup that hit a network error / 8s timeout / 5xx was handed
// straight to a `mailto:` composer. That is lossy by design: it depends on the
// user actually pressing send in their mail client, and on us reconciling the
// support inbox into Brevo list 4 forever (AGE-61's hourly reconciler). 20 of
// 21 signups were lost that way before that reconciler existed.
//
// Instead we persist the pending signup on the device and retry it on the next
// foreground / connectivity. `mailto:` survives only as a last-resort,
// USER-INITIATED escape hatch once retries are clearly not working.
//
// Lives in this file (rather than its own module) so it stays a dependency-free
// leaf that `node --test` can run directly, the same constraint that keeps
// react-native imports out of here. Storage and the clock are injected; the
// production AsyncStorage adapter is waitlist-queue-storage.ts.
export const WAITLIST_QUEUE_KEY = "opencode.waitlist.pending.v1"
/** Cap the queue so a broken device can't grow storage without bound. */
export const WAITLIST_QUEUE_MAX = 5
/** After this many failed attempts the UI offers the manual email escape hatch. */
export const WAITLIST_QUEUE_ATTEMPTS_BEFORE_MANUAL = 3
/** Entries older than this are dropped: the address is stale, the user moved on. */
export const WAITLIST_QUEUE_TTL_MS = 30 * 24 * 60 * 60 * 1000 // 30 days
export interface QueuedSignup {
/** Already normalized (trimmed/lowercased) by normalizeWaitlistEmail. */
email: string
/** Epoch ms of the first attempt. */
queuedAt: number
/** Number of failed submit attempts so far (>= 1 — enqueue follows a failure). */
attempts: number
/** Epoch ms of the last attempt. */
lastAttemptAt: number
/** Last transport/server error, for diagnostics only. */
lastError?: string
}
/**
* Minimal storage port. AsyncStorage satisfies this structurally, so does a
* plain Map in tests. Deliberately not typed against AsyncStorage so this file
* stays importable by `node --test`.
*/
export interface QueueStorage {
getItem(key: string): Promise<string | null>
setItem(key: string, value: string): Promise<void>
removeItem(key: string): Promise<void>
}
function isQueuedSignup(value: unknown): value is QueuedSignup {
if (typeof value !== "object" || value === null) return false
const entry = value as Record<string, unknown>
return (
typeof entry.email === "string" &&
normalizeWaitlistEmail(entry.email) === entry.email &&
typeof entry.queuedAt === "number" &&
Number.isFinite(entry.queuedAt) &&
typeof entry.attempts === "number" &&
Number.isFinite(entry.attempts)
)
}
/** Reads the queue, tolerating absent/corrupt/foreign JSON (never throws). */
export async function loadQueue(storage: QueueStorage): Promise<QueuedSignup[]> {
let raw: string | null = null
try {
raw = await storage.getItem(WAITLIST_QUEUE_KEY)
} catch {
return []
}
if (!raw) return []
try {
const parsed: unknown = JSON.parse(raw)
if (!Array.isArray(parsed)) return []
return parsed.filter(isQueuedSignup).map((entry) => ({
...entry,
lastAttemptAt: typeof entry.lastAttemptAt === "number" ? entry.lastAttemptAt : entry.queuedAt,
}))
} catch {
return []
}
}
async function saveQueue(storage: QueueStorage, queue: QueuedSignup[]): Promise<void> {
try {
if (queue.length === 0) await storage.removeItem(WAITLIST_QUEUE_KEY)
else await storage.setItem(WAITLIST_QUEUE_KEY, JSON.stringify(queue))
} catch {
// Storage unavailable: the retry is lost, but the caller already told the
// user the truth ("saved, we'll finish signing you up") only when this
// resolves. enqueueSignup() surfaces the failure via its return value.
throw new Error("waitlist queue storage unavailable")
}
}
/** Drops entries past the TTL. Exported for the test that pins the policy. */
export function pruneQueue(queue: QueuedSignup[], now: number): QueuedSignup[] {
return queue.filter((entry) => now - entry.queuedAt < WAITLIST_QUEUE_TTL_MS)
}
/**
* Persists a failed signup for later retry. Dedupes by email (an impatient
* user tapping twice must not produce two entries) and keeps the newest
* WAITLIST_QUEUE_MAX entries.
*
* Returns the stored entry, or null when the email is invalid or storage
* refused the write — in that case the caller must NOT tell the user we saved it.
*/
export async function enqueueSignup(
storage: QueueStorage,
rawEmail: string,
options: { now?: number; error?: string } = {},
): Promise<QueuedSignup | null> {
const email = normalizeWaitlistEmail(rawEmail)
if (email === null) return null
const now = options.now ?? Date.now()
const existing = pruneQueue(await loadQueue(storage), now)
const previous = existing.find((entry) => entry.email === email)
const entry: QueuedSignup = {
email,
queuedAt: previous?.queuedAt ?? now,
attempts: (previous?.attempts ?? 0) + 1,
lastAttemptAt: now,
...(options.error ? { lastError: options.error } : {}),
}
const next = [...existing.filter((e) => e.email !== email), entry].slice(-WAITLIST_QUEUE_MAX)
try {
await saveQueue(storage, next)
} catch {
return null
}
return entry
}
export async function removeFromQueue(storage: QueueStorage, email: string): Promise<void> {
const queue = await loadQueue(storage)
const next = queue.filter((entry) => entry.email !== email)
if (next.length === queue.length) return
try {
await saveQueue(storage, next)
} catch {
// best effort
}
}
export interface FlushOutcome {
/** Entries that reached the server (Brevo list 4). */
synced: string[]
/** Entries the server permanently rejected (4xx) — dropped, retrying can't help. */
rejected: string[]
/** Entries still pending after this flush. */
pending: QueuedSignup[]
}
export type SubmitFn = (email: string) => Promise<WaitlistResult>
/**
* Retries every pending signup once. Called on app foreground and when the
* waitlist screen mounts.
*
* - ok -> drop (it's in Brevo now)
* - 4xx -> drop (the server will never accept this address)
* - network/5xx -> keep, bump attempts (retry next foreground)
*
* Never throws: a flush is a background best-effort action.
*/
export async function flushQueue(
storage: QueueStorage,
options: { submit?: SubmitFn; now?: number } = {},
): Promise<FlushOutcome> {
const now = options.now ?? Date.now()
const submit = options.submit ?? ((email: string) => submitWaitlistSignup(email))
const queue = pruneQueue(await loadQueue(storage), now)
const synced: string[] = []
const rejected: string[] = []
const pending: QueuedSignup[] = []
for (const entry of queue) {
let result: WaitlistResult
try {
result = await submit(entry.email)
} catch (error: unknown) {
pending.push({ ...entry, attempts: entry.attempts + 1, lastAttemptAt: now, lastError: String(error) })
continue
}
if (result.ok) {
synced.push(entry.email)
} else if (!result.retryable) {
rejected.push(entry.email)
} else {
pending.push({ ...entry, attempts: entry.attempts + 1, lastAttemptAt: now, lastError: result.error })
}
}
try {
await saveQueue(storage, pending)
} catch {
// Storage went away mid-flush; the in-memory outcome is still accurate.
}
return { synced, rejected, pending }
}
/**
* True once an entry has failed enough times that we should stop implying
* "we'll handle it" and offer the manual email escape hatch instead.
*/
export function needsManualEscapeHatch(entry: QueuedSignup | null | undefined): boolean {
return !!entry && entry.attempts >= WAITLIST_QUEUE_ATTEMPTS_BEFORE_MANUAL
}