Compare commits

...

10 Commits

Author SHA1 Message Date
xieyao
f0185cbd23 feat: full Chinese (zh-Hans) localization — i18n all hardcoded strings, zh-CN download page
Some checks failed
Daily Product Intelligence / report (push) Has been cancelled
Play Store Review Triage / triage (push) Has been cancelled
Sentry noise-gate report / report (push) Has been cancelled
2026-09-09 04:05:43 +08:00
engineer
646f9cbf77 docs(retro): record the AGE-497 stale-SENTRY_ORG-secret lesson
Co-Authored-By: Paperclip <noreply@paperclip.ing>
2026-08-18 23:17:33 -07:00
engineer
709aa97bad chore(ci): remove the AGE-497 throwaway Sentry egress probe workflow
Root cause found and fixed: the repo secret SENTRY_ORG (last set 2026-07-22,
same day as the stale SENTRY_PRODUCT_INTELLIGENCE_TOKEN from AGE-497's root
cause #1) held a stale org slug, not 'vibetechnologies'. Every org-scoped
Sentry API call 403'd in CI while /auth/ (identity-only, not org-scoped)
kept succeeding — which is what made it look like a network/IP-level block
instead of a bad secret value. Confirmed by hardcoding org=vibetechnologies
in the probe from the same runner/IP: 200. Fixed by resetting the SENTRY_ORG
secret to vibetechnologies; the real 'Sentry noise-gate report' workflow now
succeeds (run 32222675122).

Co-Authored-By: Paperclip <noreply@paperclip.ing>
2026-08-18 23:16:52 -07:00
engineer
f1f61d0516 debug(ci): test whether secrets.SENTRY_ORG (set 2026-07-22, same day as the stale product-intelligence token) is itself stale
Co-Authored-By: Paperclip <noreply@paperclip.ing>
2026-08-18 23:14:42 -07:00
engineer
ad2c73d11e debug(ci): add org-detail and Node-fetch probes to narrow the 403 down
Isolates whether the 403 is curl/UA-specific (vs the plain fetch() the
failing script actually uses) and whether it's specific to the listing
endpoint vs a non-listing org-detail GET.

Co-Authored-By: Paperclip <noreply@paperclip.ing>
2026-08-18 23:12:46 -07:00
engineer
34ce8e8550 debug(ci): tee probe output to stdout so gh run view --log captures it
Co-Authored-By: Paperclip <noreply@paperclip.ing>
2026-08-18 23:09:32 -07:00
engineer
859537b2cd debug(ci): add throwaway workflow to probe Sentry API from Actions egress IP
AGE-497: same Sentry token verified 200 locally returns 403 from GitHub
Actions runners on /organizations/{org}/projects/. Add a workflow_dispatch
probe that prints the runner's public egress IP and retries the exact
failing call with verbose headers, to distinguish an Actions-IP block from
a token/scope problem before deciding the fix.

Co-Authored-By: Paperclip <noreply@paperclip.ing>
2026-08-18 23:06:38 -07:00
Den
ac2fdcfc16 docs(release): record v0.4.15 and make the versionCode bump non-optional (#181)
Two things the v0.4.15 cut got wrong were things this doc told it to do.

- PUBLISHING.md step 1 said not to bother hand-bumping `android.versionCode`.
  That is true for Play (CI overrides it with run_number+100) and false for the
  two channels that carry most of the install base: F-Droid and direct APK key
  upgrades off versionCode, so reusing the previous one means the release is
  never offered to anyone who already has that code. Step 1 now lists all four
  places to bump plus the changelog named after the code, and step 5 adds the
  two channel checks (GitHub release — the source the in-app update check polls
  — and the F-Droid index) that were previously implied to be unnecessary.
- docs/playstore.md release history: v0.4.15 at production versionCode 153,
  the first release to reach production from a tag push alone (#177), plus the
  superseded 152 from the pre-#180 tag.

Co-authored-by: engineer <engineer@gray-knight-m1.local>
Co-authored-by: Paperclip <noreply@paperclip.ing>
2026-08-14 11:32:55 -07:00
Den
4d64700b1b tools(sentry): anchor the measurement windows on the gate's rollout instant (#178)
A window that spans the 2026-08-14 14:22Z production rollout contains devices
that could not possibly have run the gate. Its rate is neither a baseline nor a
result, and it prints identically to both. This was not hypothetical: a
`post=08-14T07:00Z..now` window (84% of it pre-rollout) was run against this
script and reported opencode-mobile *rising* to 4.44/h.

`noise-gate-report.mjs` shipped with the same defect built into its default:
`post = now-7d..now` straddles the rollout on every run before 08-21, diluting
the after-rate toward baseline - biased toward grading the gate as ineffective
on exactly the dates the ticket schedules its reads (08-17, 08-21).

- sentry-volume-report: `--since-rollout` reads the instant from the release
  history table in docs/playstore.md (production versionCode >= 150, earliest
  such release, so a later v0.4.15 does not restart the window) and splits
  there. Every window is labelled [pre]/[post]/[mixed]; mixed prints how much
  of it predates the gate, a young post window prints its uptake age, and an
  unparseable table reports "unknown" rather than assuming post.
- noise-gate-report: defaults post to the rollout instant, returns UNGRADED for
  a mixed/unknown post window, and pins the baseline to the documented
  post-box-bot-fix window instead of a 7d lookback that dragged ~22k/mo of
  already-fixed box-bot volume into the org outlook (it read "MISSES by 18,612"
  for a dead reason; now 628/mo, clears).
- before_send == 0 is now reported as expected in a pre/mixed/young window and
  as a failure only after 24h+ of gated production.

Re-probed every server-side lever with a WRITE-scoped token so none of the
answers is a permissions artifact, and corrected the record in docs/analytics.md:
per-key rate limit returns 200 and silently drops the field; error-message
filters return 400 "You do not have that feature enabled" (a plan gate, not
absence - it is the one lever that would reach never-updating installs); spike
protection is not 403-unavailable, it is already enabled everywhere and simply
does not fire on sustained baseline volume.

Co-authored-by: engineer <engineer@macbookpro.lan>
2026-08-14 10:54:43 -07:00
Den
c43ec27a8c fix(release): give v0.4.15 its own versionCode so the release can actually ship (#180)
The v0.4.15 release commit (f564869) bumped package.json and app.json
`expo.version` but left `android/app/build.gradle` at versionCode 41 /
versionName "0.4.14" and app.json `android.versionCode` at 41. Consequences,
all observed on tag v0.4.15:

- `npm run check:versions` fails, so the F-Droid publish (run 31820921979)
  died at its first step and the `release` job in build.yml never ran — no
  GitHub release exists for v0.4.15. That is the source the new in-app update
  check polls, so the mechanism this release exists to ship had nothing to
  find.
- versionCode 41 is v0.4.14's. F-Droid and every direct-APK install key
  upgrades off versionCode, so even a successful publish would not have been
  offered to the 0.4.10/0.4.14 cohort. Play was unaffected only because the
  publish workflow overrides the code with run_number+100.

Fix is the missing half of the release bump: versionCode 42 / versionName
0.4.15, plus the changelog files named after the code (distribution/ for the
record, fastlane/ for F-Droid).

check-version-parity.mjs now also requires distribution/changelogs/<code>.txt
to exist and to describe the version being released, and the fastlane copy to
exist. A stale versionCode is otherwise internally consistent and silent;
verified it discriminates — code 41 with version 0.4.15 fails, 42 passes.

Tests: npm test 320 pass.

Co-authored-by: engineer <engineer@macbookpro.lan>
Co-authored-by: Paperclip <noreply@paperclip.ing>
2026-08-14 10:33:38 -07:00
25 changed files with 602 additions and 83 deletions

View File

@@ -86,3 +86,13 @@ Never repeat the same uid target more than 3 times.
**Time cost**: [how much was wasted and what would have saved it]
```
## 2026-08-19: AGE-497 — Sentry 403 in CI looked like an IP block, was a stale repo secret
**Problem**: `Sentry noise-gate report` workflow got `403 {"detail":"You do not have permission..."}` from GitHub Actions on every org-scoped Sentry call (`/organizations/{org}/projects/`, `/organizations/{org}/`), even after the auth token itself was fixed and verified 200 locally. `/auth/` (identity-only, not org-scoped) kept returning 200 from the same runner/IP, which made "Actions egress IP is blocked by Sentry" the leading theory.
**Mistake avoided (barely)**: about to escalate for a Sentry-support IP-allowlist ticket or a self-hosted-runner migration, both slow and outside our control. A 2-minute A/B probe (same runner, same call, but with the org slug hardcoded instead of read from `secrets.SENTRY_ORG`) returned 200 — proving the *token and network path were fine* and the *secret value itself* was wrong. `SENTRY_ORG` had been set on 2026-07-22, the same day the sibling `SENTRY_PRODUCT_INTELLIGENCE_TOKEN` secret went stale (AGE-497 root cause #1) — same bad edit touched both secrets.
**Correct pattern**: when a 403/401 differs between "identity" endpoints and "resource-scoped" endpoints for the *same* token from the *same* origin, suspect the resource identifier (org slug, project slug, tenant id) before suspecting network/IP infrastructure. Test it directly: hardcode the known-good identifier in a throwaway CI step and compare status codes from the same runner in the same run — this isolates secret-value bugs from token/network bugs in one API call, no waiting on a second infra channel.
**Time cost**: ~20 min from wake to fix, because the probe workflow escalated one variable at a time (egress IP → curl vs Node fetch → listing vs detail endpoint → hardcoded org) instead of jumping straight to the identifier-substitution test. Next time: when two endpoints on the same token/origin diverge in status code, test identifier substitution first — it's the cheapest experiment that can falsify "it's the network."

View File

@@ -133,13 +133,16 @@ jobs:
run: ./gradlew bundleRelease
- name: Verify the Sentry noise gate is in the artifact
# AGE-105: the org error quota is defended ONLY by the client-side gate
# (every server-side lever on this Sentry plan was checked and is dead:
# per-key rate limit silently no-ops with a 200, custom inbound filters
# absent, spike protection 403). If a build ships without the gate — or
# without a DSN, which makes Sentry a silent no-op — the org goes back
# over quota, and while it is over quota Sentry stores nothing, so the
# regression is invisible in Sentry itself until the monthly reset.
# AGE-105: the org error quota is defended ONLY by the client-side gate.
# Every server-side lever on this Sentry plan was probed and is dead
# (docs/analytics.md): per-key rate limit answers 200 and silently drops
# the field, custom error-message filters answer 400 "You do not have
# that feature enabled", and spike protection is already on everywhere
# but only catches spikes, not this steady baseline. If a build ships
# without the gate — or without a DSN, which makes Sentry a silent
# no-op — the org goes back over quota, and while it is over quota
# Sentry stores nothing, so the regression is invisible in Sentry itself
# until the monthly reset.
# Grep the shipped Hermes bundle instead. Verified to discriminate:
# v0.4.14 passes, pre-gate v0.4.13 fails.
run: node scripts/verify-release-bundle.mjs android/app/build/outputs/bundle/release/app-release.aab

View File

@@ -83,6 +83,6 @@ jobs:
echo ''
echo '### Raw org volume'
echo '```'
node scripts/sentry-volume-report.mjs --by-reason || true
node scripts/sentry-volume-report.mjs --by-reason --since-rollout || true
echo '```'
} >> "$GITHUB_STEP_SUMMARY"

View File

@@ -44,11 +44,13 @@ It builds an AAB (Android App Bundle), signs it with the release keystore, and u
## Releasing (proven runbook)
1. Bump `version` in `package.json` **and** `app.json` (`expo.version`). Do **not** bother hand-bumping `android.versionCode` for Play — the publish workflow overrides it with `github.run_number + 100` at build time (so the Play `versionCode` is e.g. `142`, unrelated to the number in `app.json`; that field only matters for local/other builds).
2. Update the **Play** release notes in `distribution/whatsnew/whatsnew-en-US` (single file, applied to the build being uploaded; **max 500 chars**). This — not the `fastlane/metadata/android/en-US/changelogs/*.txt` files — is what the Play publish uses (`whatsNewDirectory` in the workflow). The fastlane `changelogs/*.txt` files feed **F-Droid**, not Play; keep them for F-Droid but don't expect Play to read them. Merge to `main`.
1. Bump the version in **four** places, then run `npm run check:versions` before you push: `package.json` `version`, `app.json` `expo.version`, and `android/app/build.gradle` `versionName` — plus `versionCode` in **both** `app.json` (`expo.android.versionCode`) and `build.gradle`, incremented by one.
- Play does not need the hand-bumped `versionCode` (the publish workflow overrides it with `github.run_number + 100`, so the Play code is e.g. `152`, unrelated to the `app.json` number). **F-Droid and direct-APK installs do.** They key upgrades off `versionCode`, so a release that reuses the previous one is silently never offered to anyone who already has that code installed. v0.4.15 shipped with v0.4.14's `versionCode 41` for exactly this reason (fixed in #180) — the F-Droid publish failed outright and the GitHub release never got created.
2. Add the changelog for the **new versionCode** in both `distribution/changelogs/<versionCode>.txt` and `fastlane/metadata/android/en-US/changelogs/<versionCode>.txt` (F-Droid reads the fastlane copy), and update the **Play** release notes in `distribution/whatsnew/whatsnew-en-US` (single file, applied to the build being uploaded; **max 500 chars**). `whatsnew` — not the fastlane `changelogs/*.txt` — is what the Play publish uses (`whatsNewDirectory` in the workflow). `check:versions` enforces that the changelog named after the versionCode describes the version you are releasing. Merge to `main`.
3. Tag the release: `git tag -a vX.Y.Z <sha> -m "..." && git push origin vX.Y.Z`. This triggers the publish workflow → **production** track, `status: completed`.
4. Verify the publish run is green, and read the run summary — it records the event, resolved track/status, and the real Play `versionCode` (run_number+100, not the `app.json` number).
5. Nothing else to do. There is no second promotion step.
5. Check the other two channels, because Play is only one of three and the smaller share of the install base: the **GitHub release** exists with both APKs (`build.yml`'s `release` job — this is what the in-app update check polls via `releases/latest`), and the **F-Droid index** lists the new version (`https://dzianisv.github.io/opencode-mobile/fdroid/repo/index-v1.json`).
6. Nothing else to do. There is no second promotion step.
## Promoting to production

View File

@@ -94,8 +94,8 @@ android {
applicationId 'cc.agentlabs.opencode'
minSdkVersion rootProject.ext.minSdkVersion
targetSdkVersion rootProject.ext.targetSdkVersion
versionCode 41
versionName "0.4.14"
versionCode 42
versionName "0.4.15"
buildConfigField "String", "REACT_NATIVE_RELEASE_LEVEL", "\"${findProperty('reactNativeReleaseLevel') ?: 'stable'}\""
}

View File

@@ -35,7 +35,7 @@
},
"android": {
"package": "cc.agentlabs.opencode",
"versionCode": 41,
"versionCode": 42,
"usesCleartextTraffic": true,
"adaptiveIcon": {
"foregroundImage": "./assets/adaptive-icon.png",

View File

@@ -732,7 +732,7 @@ export default function SessionsScreen() {
</Text>
<TextInput
style={[styles.modalInput, isDark && styles.modalInputDark]}
placeholder={serverHome ? `${serverHome}/...` : "/path/to/project"}
placeholder={serverHome ? `${serverHome}/...` : t("connection.add.quick.directoryPlaceholder")}
placeholderTextColor={isDark ? "#666666" : "#999999"}
value={customDir}
onChangeText={(text) => {

View File

@@ -233,7 +233,7 @@ export default function EditConnectionScreen() {
<Text style={[styles.label, isDark && styles.labelDark]}>{t("connection.shared.serverUrl")}</Text>
<TextInput
style={[styles.input, isDark && styles.inputDark]}
placeholder="http://192.168.1.100:4096"
placeholder={t("connection.edit.urlPlaceholder")}
placeholderTextColor={isDark ? "#666666" : "#999999"}
value={url}
onChangeText={setUrl}
@@ -246,7 +246,7 @@ export default function EditConnectionScreen() {
<Text style={[styles.label, isDark && styles.labelDark]}>{t("connection.shared.directoryOptional")}</Text>
<TextInput
style={[styles.input, isDark && styles.inputDark]}
placeholder="/path/to/project"
placeholder={t("connection.add.quick.directoryPlaceholder")}
placeholderTextColor={isDark ? "#666666" : "#999999"}
value={directory}
onChangeText={setDirectory}
@@ -261,7 +261,7 @@ export default function EditConnectionScreen() {
<Text style={[styles.label, isDark && styles.labelDark]}>{t("connection.shared.username")}</Text>
<TextInput
style={[styles.input, isDark && styles.inputDark]}
placeholder="admin"
placeholder={t("connection.add.quick.usernamePlaceholder")}
placeholderTextColor={isDark ? "#666666" : "#999999"}
value={username}
onChangeText={setUsername}

View File

@@ -331,7 +331,7 @@ export default function AddConnectionScreen() {
<View style={styles.ipRow}>
<TextInput
style={[styles.input, styles.ipInput, isDark && styles.inputDark]}
placeholder="192.168.1.100"
placeholder={t("connection.add.quick.ipPlaceholder")}
placeholderTextColor={isDark ? "#666666" : "#999999"}
value={ip}
onChangeText={setIp}
@@ -343,7 +343,7 @@ export default function AddConnectionScreen() {
<Text style={[styles.ipColon, isDark && styles.textDark]}>:</Text>
<TextInput
style={[styles.input, styles.portInput, isDark && styles.inputDark]}
placeholder="4096"
placeholder={t("connection.add.quick.portPlaceholder")}
placeholderTextColor={isDark ? "#666666" : "#999999"}
value={port}
onChangeText={setPort}
@@ -479,7 +479,7 @@ export default function AddConnectionScreen() {
<>
<TextInput
style={[styles.input, isDark && styles.inputDark, { marginTop: 12 }]}
placeholder="your@email.com"
placeholder={t("connection.add.quick.emailPlaceholder")}
placeholderTextColor={isDark ? "#666666" : "#999999"}
value={waitlistEmail}
onChangeText={setWaitlistEmail}
@@ -610,7 +610,7 @@ export default function AddConnectionScreen() {
<Text style={[styles.label, isDark && styles.labelDark]}>{t("connection.shared.directoryOptional")}</Text>
<TextInput
style={[styles.input, isDark && styles.inputDark]}
placeholder="/path/to/project"
placeholder={t("connection.add.quick.directoryPlaceholder")}
placeholderTextColor={isDark ? "#666666" : "#999999"}
value={directory}
onChangeText={setDirectory}
@@ -625,7 +625,7 @@ export default function AddConnectionScreen() {
<Text style={[styles.label, isDark && styles.labelDark]}>{t("connection.shared.username")}</Text>
<TextInput
style={[styles.input, isDark && styles.inputDark]}
placeholder="admin"
placeholder={t("connection.add.quick.usernamePlaceholder")}
placeholderTextColor={isDark ? "#666666" : "#999999"}
value={username}
onChangeText={setUsername}
@@ -636,7 +636,7 @@ export default function AddConnectionScreen() {
<Text style={[styles.label, isDark && styles.labelDark]}>{t("connection.shared.password")}</Text>
<TextInput
style={[styles.input, isDark && styles.inputDark]}
placeholder="password"
placeholder={t("connection.add.quick.passwordPlaceholder")}
placeholderTextColor={isDark ? "#666666" : "#999999"}
value={password}
onChangeText={setPassword}

View File

@@ -748,7 +748,7 @@ export default function SessionScreen() {
onLongPress={() => cycleAgent(-1)}
>
<View style={[s.agentDot, { backgroundColor: agentColor }]} />
<Text style={[s.agentLabel, isDark && s.textWhite]}>{agent || "build"}</Text>
<Text style={[s.agentLabel, isDark && s.textWhite]}>{agent || t("session.agentFallback")}</Text>
<Ionicons name="swap-horizontal-outline" size={12} color={isDark ? "#888888" : "#666666"} />
</TouchableOpacity>

View File

@@ -0,0 +1,5 @@
v0.4.15 — The app now tells you when a new version is out
• Installs from a downloaded APK, F-Droid or a mirror had no way to learn a newer version existed. The app now checks once a day and shows a dismissible banner when one is available.
• "Not now" is remembered for that version only.
• Settings now shows the version you are actually running instead of a hard-coded placeholder.

View File

@@ -165,6 +165,23 @@ production rollout at 14:22 UTC), two windows agreeing to within 0.2%:
Mobile was 87% of the org's post-box-bot demand. Target: under ~1,500/month, which puts the
org under the 3,500/month gate.
**Never measure across the rollout instant.** Use `--since-rollout`, which reads the v0.4.14
production instant (2026-08-14 14:22Z) out of the release-history table in
[`playstore.md`](./playstore.md) and splits the windows exactly there:
```sh
SENTRY_AUTH_TOKEN=... node scripts/sentry-volume-report.mjs --by-reason --since-rollout
```
A hand-rolled window that spans the instant mixes two populations — devices that have the
gate and devices that do not — so its rate is neither a baseline nor a result, while looking
exactly like both. This is not hypothetical: a `post=08-14T07:00Z..now` window (84% of it
pre-rollout) was run against this very script and reported mobile *rising* to 4.44/h. Such
windows now print `[mixed]` with the pre-rollout percentage, a clean but young post window
prints how many hours of uptake it has, and an unparseable release table reports `unknown`
rather than silently grading everything as post-gate
(`scripts/sentry-volume-report.test.mjs`).
### Uptake is part of the measurement, not an excuse afterwards
The gate ships **inside the app binary**, so it only runs on devices that installed v0.4.14.
@@ -235,18 +252,29 @@ box-bot at 0, mobile is now the dominant remaining demand.
### Server-side levers do not exist on this plan
Checked directly against the API on 2026-08-14, so nobody re-litigates it:
Probed directly against the API (re-verified 2026-08-14 15:45Z with a **write**-scoped
token, so none of these is a permissions artifact), so nobody re-litigates it:
| Lever | Result |
|---|---|
| Per-key rate limit (`PUT /projects/{org}/{proj}/keys/{id}/`) | **Silent no-op.** Returns HTTP 200 and drops the field; a follow-up GET always reads `rateLimit: null`. Reproduced with `window` = 60, 3600 and 86400. |
| Custom inbound filters (error message / release) | Not present. Only the five generic browser filters exist. |
| Spike protection (`/organizations/{org}/spike-protections/`) | HTTP 403. |
| Lever | Call | Result |
|---|---|---|
| Per-key rate limit | `PUT /projects/{org}/{proj}/keys/{id}/` `{"rateLimit":{"window":86400,"count":50}}` | **HTTP 200 that lies.** The field is dropped; the follow-up GET reads `rateLimit: null`. Reproduced with `window` = 60, 3600, 86400. The success code is the trap — this is the one lever that looks like it worked. |
| Custom inbound filter on error message | `PUT /projects/{org}/{proj}/` `{"options":{"filters:error_messages":"…"}}` | **HTTP 400 `{"detail":"You do not have that feature enabled"}`.** The option key exists and reads `''`; writing it is plan-gated (Business). This is the lever that *would* fix the residual risk, because it drops at ingest for **every** app version, including installs that never update. |
| Generic inbound filters | `PUT /projects/{org}/{proj}/filters/{id}/` `{"active":true}` | **HTTP 204 — works.** Useless here: the only ids are `browser-extensions`, `legacy-browsers`, `localhost`, `web-crawlers`, `filtered-transaction`. None can match a React Native app error. |
| Spike protection | `POST /organizations/{org}/spike-protections/` | **HTTP 201 — and it was already on.** Every project reads `quotas:spike-protection-disabled = false`. It did not prevent this overage because this is *sustained baseline* volume, not a spike. (The org-level GET is 403, which earlier read as "unavailable"; it is not unavailable, it is ineffective — do not spend a plan upgrade on it.) |
Org `features` is `[]`. **The client-side gate is the only control that exists**, so its
coverage is the entire safety margin — which is why `sentry-noise-production.test.ts` pins
that coverage against real production data.
Two consequences worth stating outright:
1. Because the only control ships **inside the app binary**, Play install uptake is on the
critical path of the fix. That is not a reporting detail — it is why the verdict must be
normalised by uptake (`scripts/noise-gate-report.mjs`) instead of read off raw volume.
2. Devices that never update are permanently ungated. Nothing on this plan can reach them.
If the org ever moves to Business, `filters:error_messages` is the row to revisit first;
re-probe it rather than assuming, since these answers are plan state, not physics.
### Gate coverage against 90d of real events
Every issue in the project over the 90d to 2026-08-14 (648 events), replayed through the

View File

@@ -110,10 +110,11 @@ and it is never equal to `app.json`'s committed `android.versionCode`.
### Release history (production track)
| Version | Internal versionCode (tag push) | Production versionCode (dispatch) | Production rollout (UTC) | Notes |
| Version | Internal versionCode (tag push) | Production versionCode | Production rollout (UTC) | Notes |
|---|---|---|---|---|
| v0.4.13 | 148 (run 48, cancelled) | **149** (run 49) | 2026-08-14 09:20 | waitlist retry queue (#166) |
| v0.4.14 | 150 (run 50) | **151** (run 51) | 2026-08-14 14:22 | Sentry client-side noise gate (#169) — post-deploy Sentry measurement window for AGE-105 starts at this date, not at merge |
| v0.4.13 | 148 (run 48, cancelled) | **149** (run 49, dispatch) | 2026-08-14 09:20 | waitlist retry queue (#166) |
| v0.4.14 | 150 (run 50) | **151** (run 51, dispatch) | 2026-08-14 14:22 | Sentry client-side noise gate (#169) — post-deploy Sentry measurement window for AGE-105 starts at this date, not at merge |
| v0.4.15 | — | **153** (run 53, **tag push**) | 2026-08-14 17:58 | In-app update check for non-Play installs (#179). First release to reach production **without a dispatch** — #177 verified in the wild: run [31824702844](https://github.com/dzianisv/opencode-mobile/actions/runs/31824702844) logged `event=push -> track=production status=completed`. Code 152 (run 52, 17:04) was the same version from the pre-#180 tag, superseded by 153 |
---

View File

@@ -0,0 +1,5 @@
v0.4.15 — The app now tells you when a new version is out
• Installs from a downloaded APK, F-Droid or a mirror had no way to learn a newer version existed. The app now checks once a day and shows a dismissible banner when one is available.
• "Not now" is remembered for that version only.
• Settings now shows the version you are actually running instead of a hard-coded placeholder.

4
package-lock.json generated
View File

@@ -1,12 +1,12 @@
{
"name": "@opencode-ai/mobile",
"version": "0.4.11",
"version": "0.4.15",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "@opencode-ai/mobile",
"version": "0.4.11",
"version": "0.4.15",
"dependencies": {
"@expo/vector-icons": "^15.0.3",
"@gorhom/bottom-sheet": "5.2.8",

View File

@@ -23,6 +23,37 @@ if (code !== expectedCode) {
errors.push(`Gradle versionCode ${Number.isNaN(code) ? "missing" : code} != app.json versionCode ${expectedCode}`);
}
// A versionCode left at the previous release's value is the silent half of this
// failure: version metadata is internally consistent, Play still accepts the
// upload (CI overrides the code with run_number+100), but the F-Droid repo and
// every direct-APK install key upgrades off versionCode — so a 0.4.15 APK
// carrying 0.4.14's code is never offered to the cohort it was cut for.
// The changelog file is named after the versionCode and its first line names
// the version, so requiring the two to agree pins the code to this release.
const changelogPath = `distribution/changelogs/${expectedCode}.txt`;
let changelog = null;
try {
changelog = await readFile(changelogPath, "utf8");
} catch {
errors.push(`${changelogPath} is missing — every release needs a changelog named after its versionCode`);
}
if (changelog !== null) {
const firstLine = changelog.split("\n", 1)[0].trim();
if (!firstLine.startsWith(`v${expectedName}`)) {
errors.push(
`${changelogPath} describes "${firstLine}", not v${expectedName} — versionCode ${expectedCode} belongs to an earlier release, so bump it`,
);
}
}
const fastlanePath = `fastlane/metadata/android/en-US/changelogs/${expectedCode}.txt`;
try {
await readFile(fastlanePath, "utf8");
} catch {
errors.push(`${fastlanePath} is missing — F-Droid reads its release notes from here`);
}
if (errors.length) {
console.error(errors.join("\n"));
process.exitCode = 1;

View File

@@ -51,9 +51,24 @@
*/
import { execFileSync } from "node:child_process"
import { readFile } from "node:fs/promises"
import { fileURLToPath } from "node:url"
import { dirname, join } from "node:path"
import { gateRollout, parseRolloutHistory } from "./sentry-volume-report.mjs"
/** Rollout instant of the first gated production build, or null if unrecorded.
* Never guessed: an unrecorded rollout must surface as UNGRADED, not as a
* window silently assumed to be post-gate. */
async function gateRolloutAt() {
try {
const md = await readFile(new URL("../docs/playstore.md", import.meta.url), "utf8")
return gateRollout(parseRolloutHistory(md))?.at ?? null
} catch {
return null
}
}
const HERE = dirname(fileURLToPath(import.meta.url))
const MONTH_HOURS = 730
@@ -96,7 +111,14 @@ export const TOLERANCE = Number(process.env.NOISE_GATE_TOLERANCE || 0.25)
* @param {number} [p.efficacy] share of events the gate drops on a gated device
* @param {boolean} [p.gateLive] did client_discard/before_send appear at all
*/
export function grade({ baselinePerHour, actualPerHour, gatedShare, efficacy = GATE_EFFICACY, gateLive = true }) {
export function grade({
baselinePerHour,
actualPerHour,
gatedShare,
efficacy = GATE_EFFICACY,
gateLive = true,
postPhase = "post",
}) {
const share = Math.min(Math.max(gatedShare ?? 0, 0), 1)
const expectedPerHour = baselinePerHour * (1 - share * efficacy)
const ceilingPerHour = expectedPerHour * (1 + TOLERANCE)
@@ -108,7 +130,15 @@ export function grade({ baselinePerHour, actualPerHour, gatedShare, efficacy = G
let verdict
let because
if (!gateLive) {
if (postPhase && postPhase !== "post") {
// A window that starts before the gate reached production contains devices
// that could not possibly have run it, so it dilutes the rate toward
// baseline and grades the gate as worse than it is.
verdict = "UNGRADED"
because =
`the post window is [${postPhase}] — it does not lie entirely after the gate's production rollout, ` +
"so it mixes gated and ungated devices. Re-run with a post window starting at the rollout instant."
} else if (!gateLive) {
verdict = "UNGRADED"
because =
"client_discard/before_send is 0 in this window — no device ran the gate, so nothing here measures it. " +
@@ -132,6 +162,7 @@ export function grade({ baselinePerHour, actualPerHour, gatedShare, efficacy = G
return {
verdict,
because,
postPhase,
gatedShare: share,
efficacy,
baselinePerMonth: baselinePerHour * MONTH_HOURS,
@@ -172,7 +203,12 @@ export function gatedShareFromPlay(playJson, firstGatedVersionCode = GATE_FIRST_
const gated = versions
.filter((v) => Number(v.versionCode) >= firstGatedVersionCode)
.reduce((s, v) => s + (v.users || 0), 0)
return { gated, total, share: total > 0 ? gated / total : 0, window: playJson?.window ?? null }
return {
gated,
total,
share: total > 0 ? gated / total : 0,
window: playJson?.window ?? null,
}
}
function runJson(script, args) {
@@ -194,8 +230,34 @@ function parseArgs(argv) {
else if (a === "--json") out.json = true
else if (a === "--no-play") out.play = false
}
if (!out.pre) throw new Error("--pre START..END is required (the rate before the rollout reached devices)")
if (!out.post) out.post = `${new Date(Date.now() - 7 * 86_400_000).toISOString()}..now`
return out
}
/** Documented pre-rollout baseline window (docs/analytics.md): starts after the
* AGE-55 box-bot fix landed (2026-08-14 06:19Z) and ends before the gate
* rollout (14:22Z). It is deliberately NOT "7 days before the rollout" — that
* spans the box-bot regime and would import ~22k/mo of dead volume into the
* org background estimate, making the outlook miss the gate by a mile for a
* reason that was already fixed. */
export const BASELINE_WINDOW = "2026-08-14T07:00:00Z..2026-08-14T14:00:00Z"
/** Fill in windows the caller did not pin, anchored on the rollout instant.
*
* The old default (`post = now-7d..now`) straddles the 2026-08-14 14:22Z
* rollout on every run before 08-21, so the grader's own default window mixed
* gated and ungated devices and diluted the measured rate toward baseline —
* i.e. it was biased toward reporting the gate as ineffective. The window may
* never start before the gate reached production.
*/
export function resolveWindows(args, rolloutAt, now = new Date()) {
const out = { ...args }
if (!out.pre) out.pre = BASELINE_WINDOW
if (!out.post) {
if (!rolloutAt) throw new Error("--post START..END is required (no rollout instant in docs/playstore.md)")
const weekAgo = new Date(now.getTime() - 7 * 86_400_000)
const start = weekAgo > rolloutAt ? weekAgo : rolloutAt
out.post = `${start.toISOString()}..now`
}
return out
}
@@ -205,6 +267,7 @@ function pick(report, windowName, project) {
const p = w.projects.find((x) => x.project === project)
return {
hours: w.hours,
phase: w.phase ?? null,
orgPerHour: w.orgSubmitted / w.hours,
perHour: p ? p.submittedPerHour : 0,
submitted: p ? p.submitted : 0,
@@ -216,7 +279,7 @@ function pick(report, windowName, project) {
const money = (n) => Math.round(n).toLocaleString("en-US")
async function main() {
const args = parseArgs(process.argv.slice(2))
const args = resolveWindows(parseArgs(process.argv.slice(2)), await gateRolloutAt())
const volume = runJson("sentry-volume-report.mjs", [
"--json",
@@ -240,6 +303,7 @@ async function main() {
actualPerHour: post.perHour,
gatedShare: shareInfo.share,
gateLive: post.gateDropped > 0,
postPhase: post.phase,
})
// Other projects are a background rate, not something this gate changes, so
// estimate them from whichever window is long enough to contain any of them.
@@ -255,7 +319,10 @@ async function main() {
const result = {
project: args.project,
generatedAt: new Date().toISOString(),
windows: { pre: { ...pre, spec: args.pre }, post: { ...post, spec: args.post } },
windows: {
pre: { ...pre, spec: args.pre },
post: { ...post, spec: args.post },
},
uptake: shareInfo,
grade: g,
org,

View File

@@ -5,6 +5,8 @@ import {
GATE_EFFICACY,
ORG_MONTHLY_GATE,
gatedShareFromPlay,
BASELINE_WINDOW,
resolveWindows,
grade,
orgOutlook,
} from "./noise-gate-report.mjs"
@@ -26,7 +28,11 @@ test("partial uptake is not gate failure — the mistake this script exists to p
})
test("full uptake at the replayed efficacy lands under the project target", () => {
const g = grade({ baselinePerHour: BASELINE, actualPerHour: BASELINE * (1 - GATE_EFFICACY), gatedShare: 1 })
const g = grade({
baselinePerHour: BASELINE,
actualPerHour: BASELINE * (1 - GATE_EFFICACY),
gatedShare: 1,
})
assert.equal(g.verdict, "ON_TRACK")
assert.ok(g.meetsProjectTargetAtFullUptake)
assert.ok(g.fullUptakeProjection < 150, `expected ~107/mo, got ${g.fullUptakeProjection}`)
@@ -35,27 +41,48 @@ test("full uptake at the replayed efficacy lands under the project target", () =
test("a real regression still fails even when uptake is low enough to excuse a lot", () => {
// 10% uptake excuses almost nothing; volume that did not move at all is fine,
// but volume that GREW is a finding.
const flat = grade({ baselinePerHour: BASELINE, actualPerHour: BASELINE, gatedShare: 0.1 })
const flat = grade({
baselinePerHour: BASELINE,
actualPerHour: BASELINE,
gatedShare: 0.1,
})
assert.equal(flat.verdict, "ON_TRACK", "flat volume at 10% uptake is within tolerance")
const worse = grade({ baselinePerHour: BASELINE, actualPerHour: BASELINE * 1.6, gatedShare: 0.1 })
const worse = grade({
baselinePerHour: BASELINE,
actualPerHour: BASELINE * 1.6,
gatedShare: 0.1,
})
assert.equal(worse.verdict, "OFF_TRACK")
assert.match(worse.because, /new\s+noise class|not dropping/)
})
test("a gate that does nothing on device is caught once uptake is high", () => {
const g = grade({ baselinePerHour: BASELINE, actualPerHour: BASELINE, gatedShare: 0.9 })
const g = grade({
baselinePerHour: BASELINE,
actualPerHour: BASELINE,
gatedShare: 0.9,
})
assert.equal(g.verdict, "OFF_TRACK")
assert.equal(g.impliedEfficacy, 0, "0% of the drop happened, so implied efficacy is 0")
})
test("no before_send discards means the window measures nothing — refuse to grade it", () => {
const g = grade({ baselinePerHour: BASELINE, actualPerHour: 0.01, gatedShare: 0.9, gateLive: false })
const g = grade({
baselinePerHour: BASELINE,
actualPerHour: 0.01,
gatedShare: 0.9,
gateLive: false,
})
assert.equal(g.verdict, "UNGRADED")
assert.match(g.because, /before_send/)
})
test("zero uptake is ungraded, not a pass — a quiet weekend is not efficacy", () => {
const g = grade({ baselinePerHour: BASELINE, actualPerHour: 0.2, gatedShare: 0 })
const g = grade({
baselinePerHour: BASELINE,
actualPerHour: 0.2,
gatedShare: 0,
})
assert.equal(g.verdict, "UNGRADED")
assert.equal(g.impliedEfficacy, null)
})
@@ -84,7 +111,11 @@ test("no Play rows at all reads as 0% uptake, never as a divide-by-zero pass", (
test("org outlook subtracts this project before projecting it forward", () => {
// org 5.43/h of which mobile is 4.71/h -> other projects 0.72/h ~= 526/mo
const g = grade({ baselinePerHour: BASELINE, actualPerHour: BASELINE, gatedShare: 0.5 })
const g = grade({
baselinePerHour: BASELINE,
actualPerHour: BASELINE,
gatedShare: 0.5,
})
const o = orgOutlook({
orgPerHour: 5.43,
projectPerHour: BASELINE,
@@ -96,7 +127,63 @@ test("org outlook subtracts this project before projecting it forward", () => {
})
test("org gate can still be missed by other projects even with a perfect mobile gate", () => {
const o = orgOutlook({ orgPerHour: 6, projectPerHour: 0.5, projectFullUptakePerMonth: 100 })
const o = orgOutlook({
orgPerHour: 6,
projectPerHour: 0.5,
projectFullUptakePerMonth: 100,
})
assert.equal(o.clearsOrgGate, false)
assert.ok(o.headroom < 0)
})
test("a post window that straddles the rollout is UNGRADED, not a failing grade", () => {
// The old default post window (now-7d..now) started before the 08-14 14:22Z
// rollout on every run until 08-21, mixing devices that could not have run
// the gate into the "after" rate — which biases the verdict toward failure.
const g = grade({
baselinePerHour: BASELINE,
actualPerHour: BASELINE * 0.9,
gatedShare: 0.4,
postPhase: "mixed",
})
assert.equal(g.verdict, "UNGRADED")
assert.match(g.because, /mixes gated and ungated/)
})
test("an unrecorded rollout is UNGRADED — never assumed to be post-gate", () => {
const g = grade({
baselinePerHour: BASELINE,
actualPerHour: 0.1,
gatedShare: 0.9,
postPhase: "unknown",
})
assert.equal(g.verdict, "UNGRADED", "a great-looking number from an unattributable window is not a pass")
})
test("resolveWindows never starts the post window before the gate reached production", () => {
const rollout = new Date("2026-08-14T14:22:00Z")
// Run three days in: a naive 7d lookback would reach back to 08-10, four days
// before any device could have had the gate.
const w = resolveWindows({}, rollout, new Date("2026-08-17T00:00:00Z"))
assert.equal(w.post, `${rollout.toISOString()}..now`)
// Well after rollout, a 7d lookback is entirely post-gate and is preferred:
// a longer window is what makes the other projects' background rate visible.
const later = resolveWindows({}, rollout, new Date("2026-09-01T00:00:00Z"))
assert.equal(later.post, "2026-08-25T00:00:00.000Z..now")
})
test("no rollout on record and no explicit post window is an error, not a default", () => {
assert.throws(() => resolveWindows({}, null), /--post/)
})
test("the default baseline excludes the box-bot era, which would swamp the org outlook", () => {
// A "7 days before the rollout" baseline spans the AGE-55 box-bot fix
// (2026-08-14 06:19Z) and drags ~22k/mo of already-fixed volume into the
// background estimate, so the org outlook MISSES for a dead reason.
const w = resolveWindows({}, new Date("2026-08-14T14:22:00Z"), new Date("2026-08-17T00:00:00Z"))
assert.equal(w.pre, BASELINE_WINDOW)
const [start, end] = BASELINE_WINDOW.split("..").map((s) => new Date(s))
assert.ok(start >= new Date("2026-08-14T06:19:00Z"), "baseline must start after the box-bot fix")
assert.ok(end <= new Date("2026-08-14T14:22:00Z"), "baseline must end before the gate rollout")
})

View File

@@ -12,9 +12,15 @@
//
// Usage:
// SENTRY_AUTH_TOKEN=... node scripts/sentry-volume-report.mjs \
// --by-reason --org vibetechnologies \
// --window "pre=2026-08-13T14:00:00Z..2026-08-14T06:00:00Z" \
// --window "post=2026-08-14T14:22:00Z..now"
// --by-reason --org vibetechnologies --since-rollout
//
// `--since-rollout` is the safe default for this ticket: it reads the gate's
// production rollout instant out of docs/playstore.md and splits the windows
// exactly there. Hand-rolled `--window` specs are still accepted, and are
// labelled [pre]/[post]/[mixed] so a window that straddles the rollout cannot
// masquerade as a result (that mistake was made against this very script:
// a "post" window starting 7h before the rollout showed opencode-mobile
// *rising*, because it was mostly pre-gate traffic).
//
// Notes on reading the output:
// * The headline metric is `submitted` = accepted + rate_limited: every event
@@ -47,18 +53,95 @@
const API = "https://sentry.io/api/0"
const MONTH_HOURS = 730
/** First Play versionCode that ships the client-side noise gate (v0.4.14). */
export const GATE_FIRST_VERSION_CODE = Number(process.env.GATE_FIRST_VERSION_CODE || 150)
/** Where the production rollout instants are recorded. Single source of truth:
* the same table the publish workflow makes humans update after a dispatch. */
const PLAYSTORE_DOC = new URL("../docs/playstore.md", import.meta.url)
function parseArgs(argv) {
const out = { org: process.env.SENTRY_ORG ?? "vibetechnologies", windows: [] }
const out = {
org: process.env.SENTRY_ORG ?? "vibetechnologies",
windows: [],
}
for (let i = 0; i < argv.length; i++) {
const a = argv[i]
if (a === "--org") out.org = argv[++i]
else if (a === "--window") out.windows.push(argv[++i])
else if (a === "--json") out.json = true
else if (a === "--by-reason") out.byReason = true
else if (a === "--since-rollout") out.sinceRollout = true
else if (a === "--rollout") out.rolloutOverride = argv[++i]
}
return out
}
/** Rows of the "Release history (production track)" table in docs/playstore.md.
* Only production rows with a parseable versionCode AND rollout instant count —
* a build that never reached the production track never reached a user. */
export function parseRolloutHistory(markdown) {
const rows = []
for (const line of String(markdown).split("\n")) {
if (!line.trim().startsWith("|")) continue
const cells = line
.split("|")
.slice(1, -1)
.map((c) => c.trim())
if (cells.length < 4) continue
const version = cells[0]
if (!/^v\d/.test(version)) continue // header / separator / prose rows
const codeMatch = cells[2].replace(/\*/g, "").match(/\d+/)
const dateMatch = cells[3].match(/(\d{4}-\d{2}-\d{2})[ T](\d{2}:\d{2})/)
if (!codeMatch || !dateMatch) continue
const at = new Date(`${dateMatch[1]}T${dateMatch[2]}:00Z`)
if (Number.isNaN(at.getTime())) continue
rows.push({ version, versionCode: Number(codeMatch[0]), at })
}
return rows
}
/** The instant the gate first reached production. EARLIEST qualifying release,
* not the latest: a later v0.4.15 does not re-start the measurement window. */
export function gateRollout(rows, firstGatedVersionCode = GATE_FIRST_VERSION_CODE) {
const gated = rows.filter((r) => r.versionCode >= firstGatedVersionCode).sort((a, b) => a.at - b.at)
return gated[0] ?? null
}
/** Where a measurement window sits relative to the rollout instant.
*
* This exists because the report accepts arbitrary windows, and a window that
* straddles the rollout mixes two different populations — devices that have
* the gate and devices that never will until they update. Its rate is neither
* a baseline nor a result, but it prints identically to both.
*/
export function classifyWindow(win, rolloutAt) {
if (!rolloutAt) return { phase: "unknown", gatedFraction: null, postHours: null }
const start = win.start.getTime()
const end = win.end.getTime()
const t = rolloutAt.getTime()
const postMs = Math.max(0, Math.min(end, Number.MAX_SAFE_INTEGER) - Math.max(start, t))
const gatedFraction = postMs / (end - start)
const postHours = postMs / 3_600_000
if (end <= t) return { phase: "pre", gatedFraction: 0, postHours: 0 }
if (start >= t) return { phase: "post", gatedFraction: 1, postHours }
return { phase: "mixed", gatedFraction, postHours }
}
async function readRollout(args) {
if (args.rolloutOverride) {
const at = new Date(args.rolloutOverride)
if (Number.isNaN(at.getTime())) throw new Error(`bad --rollout ${args.rolloutOverride}`)
return { version: "(--rollout)", versionCode: GATE_FIRST_VERSION_CODE, at }
}
try {
const { readFile } = await import("node:fs/promises")
return gateRollout(parseRolloutHistory(await readFile(PLAYSTORE_DOC, "utf8")))
} catch {
return null
}
}
function parseWindow(spec) {
const eq = spec.indexOf("=")
if (eq < 0) throw new Error(`bad --window ${spec} (want name=START..END)`)
@@ -76,7 +159,9 @@ function parseWindow(spec) {
}
async function sentry(path, token) {
const res = await fetch(`${API}${path}`, { headers: { Authorization: `Bearer ${token}` } })
const res = await fetch(`${API}${path}`, {
headers: { Authorization: `Bearer ${token}` },
})
const body = await res.json()
if (!res.ok) throw new Error(`${path} -> ${res.status} ${JSON.stringify(body)}`)
return body
@@ -124,6 +209,22 @@ async function main() {
console.error("SENTRY_AUTH_TOKEN is not set. This script is read-only; a scoped read token is enough.")
process.exit(2)
}
const rollout = await readRollout(args)
if (args.sinceRollout) {
if (!rollout) {
console.error(
"--since-rollout: no production release with versionCode >= " +
`${GATE_FIRST_VERSION_CODE} found in docs/playstore.md. Pass --rollout <iso> ` +
"or record the rollout there; do NOT guess the boundary.",
)
process.exit(2)
}
const t = rollout.at.getTime()
args.windows.push(`pre=${new Date(t - 86_400_000).toISOString()}..${rollout.at.toISOString()}`)
args.windows.push(`post=${rollout.at.toISOString()}..now`)
}
if (args.windows.length === 0) {
// Default: last 7 days, one window. Enough to certify a monthly rate.
const end = new Date()
@@ -138,7 +239,18 @@ async function main() {
results.push({ win, rows: await windowStats(args.org, token, win) })
}
const report = { org: args.org, generatedAt: new Date().toISOString(), windows: [] }
const report = {
org: args.org,
generatedAt: new Date().toISOString(),
rollout: rollout
? {
version: rollout.version,
versionCode: rollout.versionCode,
at: rollout.at.toISOString(),
}
: null,
windows: [],
}
for (const { win, rows } of results) {
const projects = []
let orgSubmitted = 0
@@ -165,6 +277,7 @@ async function main() {
projects.sort((a, b) => b.submitted - a.submitted)
report.windows.push({
name: win.name,
...classifyWindow(win, rollout?.at ?? null),
start: win.start.toISOString(),
end: win.end.toISOString(),
hours: Number(win.hours.toFixed(2)),
@@ -179,8 +292,36 @@ async function main() {
return
}
if (rollout) {
console.log(
`\ngate rollout: ${rollout.version} (versionCode ${rollout.versionCode}) to Play production ` +
`at ${rollout.at.toISOString()} [docs/playstore.md]`,
)
} else {
console.log(
"\ngate rollout: UNKNOWN (no production release >= versionCode " +
`${GATE_FIRST_VERSION_CODE} in docs/playstore.md). Windows cannot be ` +
"classified pre/post; every rate below is unattributed.",
)
}
for (const w of report.windows) {
console.log(`\n== ${w.name} ${w.start} -> ${w.end} (${w.hours}h)`)
console.log(`\n== ${w.name} ${w.start} -> ${w.end} (${w.hours}h) [${w.phase}]`)
// A window that straddles the rollout is neither a baseline nor a result.
// Printed loudly because it is indistinguishable from both in the table.
if (w.phase === "mixed") {
const pre = Math.round((1 - w.gatedFraction) * 100)
console.log(
` !! MIXED WINDOW: ${pre}% of it predates the gate rollout. This rate is not a ` +
"post-gate rate and not a baseline; split it at the rollout instant (--since-rollout).",
)
}
if (w.phase === "post" && w.postHours < 24) {
console.log(
` note: only ${w.postHours.toFixed(1)}h since rollout. Play uptake is hours-to-days, so ` +
"most events here still come from ungated installs. Not yet a verdict.",
)
}
console.log(
`${"project".padEnd(24)}${"submitted".padStart(11)}${"accepted".padStart(10)}${"rate_lim".padStart(10)}${"cli_disc".padStart(10)}${"sub/h".padStart(9)}${"sub/mo".padStart(10)}`,
)
@@ -202,7 +343,15 @@ async function main() {
`client_discard split: before_send (our gate) ${fmt(gate)} | ratelimit_backoff (org over quota) ${fmt(backoff)}`,
)
if (gate === 0) {
console.log(" before_send == 0 -> no device is running the noise gate yet in this window.")
// Only alarming once enough time has passed for Play to convert installs.
// In a pre/mixed/fresh-post window, zero is the expected reading.
const mature = w.phase === "post" && w.postHours >= 24
console.log(
mature
? " before_send == 0 after 24h+ of gated production -> the gate is NOT running on devices."
: " before_send == 0 -> expected for this window (" +
`${w.phase}${w.postHours != null ? `, ${w.postHours.toFixed(1)}h post-rollout` : ""}); not evidence either way.`,
)
}
if (args.byReason) {
@@ -218,11 +367,14 @@ async function main() {
"\nGate: org submitted (accepted + rate_limited) < 3,500/month." +
"\nWindows under ~24h rank sources but do not certify a rate." +
"\nGate liveness: client_discard/before_send > 0 for opencode-mobile." +
"\nNever compare across the rollout instant with one window: [mixed] is not a result." +
"\nDo NOT segment by release: over-quota events are never stored, so release tags stop.",
)
}
if (import.meta.url === `file://${process.argv[1]}`) {
main().catch((err) => {
console.error(err.message)
process.exit(1)
})
}

View File

@@ -0,0 +1,84 @@
import assert from "node:assert/strict"
import { readFileSync } from "node:fs"
import test from "node:test"
import { GATE_FIRST_VERSION_CODE, classifyWindow, gateRollout, parseRolloutHistory } from "./sentry-volume-report.mjs"
const win = (start, end) => ({
start: new Date(start),
end: new Date(end),
hours: (new Date(end) - new Date(start)) / 3_600_000,
})
const ROLLOUT = new Date("2026-08-14T14:22:00Z")
test("the real docs/playstore.md yields the gate's production rollout instant", () => {
const rows = parseRolloutHistory(readFileSync(new URL("../docs/playstore.md", import.meta.url), "utf8"))
assert.ok(rows.length >= 2, "release history table should parse")
const gate = gateRollout(rows)
assert.ok(gate, "a production release >= the gate versionCode must be recorded")
assert.ok(gate.versionCode >= GATE_FIRST_VERSION_CODE)
assert.equal(gate.at.toISOString(), ROLLOUT.toISOString())
assert.equal(gate.version, "v0.4.14")
})
test("production versionCode is read, not the internal tag-push one", () => {
// The bold production code (151) is what reached users; 150 only ever went to
// the internal track. Reading column 1 would date the rollout to the wrong row.
const rows = parseRolloutHistory(`
| Version | Internal versionCode | Production versionCode | Production rollout (UTC) | Notes |
|---|---|---|---|---|
| v0.4.14 | 150 (run 50) | **151** (run 51) | 2026-08-14 14:22 | gate |
`)
assert.deepEqual(
rows.map((r) => [r.version, r.versionCode]),
[["v0.4.14", 151]],
)
})
test("a build that never reached production is not a rollout", () => {
// No production versionCode and no rollout instant -> it reached no device.
const rows = parseRolloutHistory(`
| v0.4.15 | 152 (run 52) | — | — | internal only |
| v0.4.14 | 150 (run 50) | **151** (run 51) | 2026-08-14 14:22 | gate |
`)
assert.equal(rows.length, 1)
assert.equal(gateRollout(rows).version, "v0.4.14")
})
test("a later gated release does not re-start the measurement window", () => {
const rows = parseRolloutHistory(`
| v0.4.15 | 152 (run 52) | **153** (run 53) | 2026-08-20 09:00 | later |
| v0.4.14 | 150 (run 50) | **151** (run 51) | 2026-08-14 14:22 | gate |
`)
assert.equal(gateRollout(rows).at.toISOString(), ROLLOUT.toISOString())
})
test("the mistake this guard exists for: a 'post' window that predates the rollout", () => {
// Actually run on 2026-08-14: --window post=07:00Z..now, rollout was 14:22Z.
// 84% of it was pre-gate traffic, and it reported opencode-mobile *rising*.
const c = classifyWindow(win("2026-08-14T07:00:00Z", "2026-08-14T15:41:00Z"), ROLLOUT)
assert.equal(c.phase, "mixed")
assert.ok(c.gatedFraction < 0.2, `only ${(c.gatedFraction * 100).toFixed(0)}% is post-gate`)
})
test("clean pre/post windows classify, and the boundary belongs to neither side twice", () => {
assert.equal(classifyWindow(win("2026-08-13T14:22:00Z", "2026-08-14T14:22:00Z"), ROLLOUT).phase, "pre")
const post = classifyWindow(win("2026-08-14T14:22:00Z", "2026-08-15T14:22:00Z"), ROLLOUT)
assert.equal(post.phase, "post")
assert.equal(post.gatedFraction, 1)
assert.equal(post.postHours, 24)
})
test("a fresh post window reports its age, so before_send == 0 is not read as failure", () => {
const c = classifyWindow(win("2026-08-14T14:22:00Z", "2026-08-14T15:43:00Z"), ROLLOUT)
assert.equal(c.phase, "post")
assert.ok(c.postHours < 24, "under a day of uptake cannot certify anything")
})
test("no known rollout is 'unknown', never silently 'post'", () => {
// Absence of the record must not be reported as a clean post-gate reading.
assert.equal(classifyWindow(win("2026-08-14T14:22:00Z", "2026-08-15T14:22:00Z"), null).phase, "unknown")
assert.equal(gateRollout(parseRolloutHistory("no table here")), null)
assert.equal(gateRollout(parseRolloutHistory("| v0.4.13 | 148 | **149** | 2026-08-14 09:20 | pre-gate |")), null)
})

View File

@@ -87,7 +87,7 @@ export function DirectorySwitcher({ sheetRef, current, recents, serverHome, isDa
<View style={s.inputWrap}>
<BottomSheetTextInput
style={[s.input, isDark && s.inputDark]}
placeholder={serverHome ? `${serverHome}/...` : "/path/to/project"}
placeholder={serverHome ? `${serverHome}/...` : t("chat.directorySwitcher.pathPlaceholder")}
placeholderTextColor={isDark ? "#666666" : "#999999"}
value={custom}
onChangeText={(text) => {

View File

@@ -4,6 +4,7 @@ import { Ionicons } from "@expo/vector-icons"
import { Markdown } from "../markdown"
import { ToolCallCard } from "./ToolCallCard"
import { ReasoningBlock } from "./ReasoningBlock"
import { useTranslation } from "react-i18next"
import type { Message, Part } from "../../lib/sdk"
const SCREEN_WIDTH = Dimensions.get("window").width
@@ -26,6 +27,7 @@ interface Props {
// built-in block-level memoization that eliminates re-renders for stable blocks
export const MessageBubble = memo(
function MessageBubble({ message, parts, isDark, onLongPress }: Props) {
const { t } = useTranslation()
const isUser = message.role === "user"
const textParts = parts.filter((p) => p.type === "text")
@@ -55,7 +57,7 @@ export const MessageBubble = memo(
size={14}
color={isUser ? (isDark ? "#ffffff" : "#0a0a0a") : "#8b5cf6"}
/>
<Text style={[s.role, isUser && s.roleUser, isDark && s.textWhite]}>{isUser ? "You" : "Assistant"}</Text>
<Text style={[s.role, isUser && s.roleUser, isDark && s.textWhite]}>{t("chat.messageBubble.roleYou")}</Text>
{message.model && <Text style={[s.modelTag, isDark && s.modelTagDark]}>{message.model.modelID}</Text>}
{!isUser && message.modelID && <Text style={[s.modelTag, isDark && s.modelTagDark]}>{message.modelID}</Text>}
</View>
@@ -104,8 +106,8 @@ export const MessageBubble = memo(
{/* Tokens/cost for assistant messages */}
{!isUser && message.tokens && (
<Text style={[s.tokens, isDark && s.tokensDark]}>
{message.tokens.input + message.tokens.output} tokens
{message.cost ? ` · $${message.cost.toFixed(4)}` : ""}
{message.tokens.input + message.tokens.output}{t("chat.messageBubble.tokensSuffix")}
{message.cost ? ` · ${t("chat.messageBubble.costPrefix")}${message.cost.toFixed(4)}` : ""}
</Text>
)}
</TouchableOpacity>

View File

@@ -1,6 +1,7 @@
import { useState } from "react"
import { View, Text, TouchableOpacity, StyleSheet, useColorScheme, Platform, ScrollView } from "react-native"
import * as Clipboard from "expo-clipboard"
import { useTranslation } from "react-i18next"
import { WIDE_CONTENT_SCROLL_CONFIG } from "../../lib/scroll-config"
interface Props {
@@ -9,6 +10,7 @@ interface Props {
}
export function CodeBlock({ code, language }: Props) {
const { t } = useTranslation()
const isDark = useColorScheme() === "dark"
const [copied, setCopied] = useState(false)
@@ -23,9 +25,9 @@ export function CodeBlock({ code, language }: Props) {
return (
<View style={[styles.container, isDark && styles.containerDark]}>
<View style={[styles.header, isDark && styles.headerDark]}>
<Text style={[styles.language, isDark && styles.languageDark]}>{language || "code"}</Text>
<Text style={[styles.language, isDark && styles.languageDark]}>{language || t("common.fallbackLanguage")}</Text>
<TouchableOpacity onPress={copy} hitSlop={8}>
<Text style={[styles.copyBtn, isDark && styles.copyBtnDark]}>{copied ? "Copied!" : "Copy"}</Text>
<Text style={[styles.copyBtn, isDark && styles.copyBtnDark]}>{copied ? t("common.copied") : t("common.copy")}</Text>
</TouchableOpacity>
</View>
<ScrollView {...WIDE_CONTENT_SCROLL_CONFIG} testID="code-block-scroll" contentContainerStyle={styles.codeScroll}>

View File

@@ -6,7 +6,10 @@
"delete": "Delete",
"retry": "Retry",
"save": "Save",
"shareReport": "Share report"
"shareReport": "Share report",
"copy": "复制",
"copied": "已复制",
"fallbackLanguage": "代码"
},
"settings": {
"sections": {
@@ -123,7 +126,8 @@
"imageFailedMessage": "One or more images could not be processed. Please try a different photo.",
"speechErrorTitle": "Voice input failed",
"speechErrorMessage": "Could not use voice input. Check your microphone permission and try again."
}
},
"agentFallback": "build"
},
"connection": {
"shared": {
@@ -159,7 +163,7 @@
"nameOptionalLabel": "Name (optional)",
"namePlaceholder": "My Mac",
"passwordIfSetLabel": "Password (if set on server)",
"passwordPlaceholder": "Leave empty if none",
"passwordPlaceholder": "password",
"usernameHintPrefix": "Username defaults to ",
"usernameHintMiddle": ". Custom username? Use ",
"usernameHintSuffix": ".",
@@ -175,7 +179,15 @@
"connectCardTitle": "OpenCode Connect",
"connectCardBadge": "Coming Soon",
"connectCardDesc": "Bridge your phone to your opencode server — no tunnel setup, no firewall config. One-tap connect from anywhere.",
"advancedLink": "Advanced options (tunnels, cloud, auth)"
"advancedLink": "Advanced options (tunnels, cloud, auth)",
"ipPlaceholder": "192.168.1.100",
"portPlaceholder": "4096",
"emailPlaceholder": "your@email.com",
"urlPlaceholderLocal": "http://192.168.1.100:4096",
"urlPlaceholderTunnel": "https://your-tunnel.trycloudflare.com",
"urlPlaceholderCloud": "https://api.opencode.ai",
"directoryPlaceholder": "/path/to/project",
"usernamePlaceholder": "admin"
},
"waitlist": {
"successText": "You're on the list — we'll email you when OpenCode Connect is ready.",
@@ -215,7 +227,8 @@
"noDetail": "no detail",
"deleteTitle": "Delete Connection",
"deleteMessage": "Are you sure you want to delete \"{{name}}\"?"
}
},
"urlPlaceholder": "http://192.168.1.100:4096"
}
},
"nav": {
@@ -388,7 +401,8 @@
"serverDefaultLabel": "Server Default",
"browseLabel": "Browse…",
"recentProjectsLabel": "Recent Projects",
"usesServerDir": "Uses server's working directory"
"usesServerDir": "Uses server's working directory",
"pathPlaceholder": "请输入路径..."
},
"directoryBrowserSheet": {
"noActiveConnection": "No active connection",
@@ -434,6 +448,12 @@
"hoursAgo": "{{count}}h ago",
"daysAgo": "{{count}}d ago"
}
},
"messageBubble": {
"roleYou": "你",
"roleAssistant": "AI 助手",
"tokensSuffix": " tokens",
"costPrefix": "$"
}
},
"demo": {

View File

@@ -6,7 +6,10 @@
"delete": "删除",
"retry": "重试",
"save": "保存",
"shareReport": "分享报告"
"shareReport": "分享报告",
"copy": "复制",
"copied": "已复制",
"fallbackLanguage": "代码"
},
"settings": {
"sections": {
@@ -123,7 +126,8 @@
"imageFailedMessage": "一张或多张图片无法处理。请尝试其他照片。",
"speechErrorTitle": "语音输入失败",
"speechErrorMessage": "无法使用语音输入。请检查麦克风权限后重试。"
}
},
"agentFallback": "build"
},
"connection": {
"shared": {
@@ -159,7 +163,7 @@
"nameOptionalLabel": "名称(可选)",
"namePlaceholder": "我的 Mac",
"passwordIfSetLabel": "密码(如服务器已设置)",
"passwordPlaceholder": "留空表示无密码",
"passwordPlaceholder": "password",
"usernameHintPrefix": "用户名默认为 ",
"usernameHintMiddle": "。需要自定义用户名?请使用",
"usernameHintSuffix": "。",
@@ -175,7 +179,15 @@
"connectCardTitle": "OpenCode Connect",
"connectCardBadge": "即将上线",
"connectCardDesc": "将手机桥接到您的 opencode 服务器 — 无需配置隧道或防火墙。一键连接,随时随地。",
"advancedLink": "高级选项(隧道、云端、身份验证)"
"advancedLink": "高级选项(隧道、云端、身份验证)",
"ipPlaceholder": "192.168.1.100",
"portPlaceholder": "4096",
"emailPlaceholder": "your@email.com",
"urlPlaceholderLocal": "http://192.168.1.100:4096",
"urlPlaceholderTunnel": "https://your-tunnel.trycloudflare.com",
"urlPlaceholderCloud": "https://api.opencode.ai",
"directoryPlaceholder": "/path/to/project",
"usernamePlaceholder": "admin"
},
"waitlist": {
"successText": "您已加入候补名单 — OpenCode Connect 准备就绪时我们会通过邮件通知您。",
@@ -215,7 +227,8 @@
"noDetail": "无详细信息",
"deleteTitle": "删除连接",
"deleteMessage": "确定要删除 \"{{name}}\" 吗?"
}
},
"urlPlaceholder": "http://192.168.1.100:4096"
}
},
"nav": {
@@ -388,7 +401,8 @@
"serverDefaultLabel": "服务器默认",
"browseLabel": "浏览…",
"recentProjectsLabel": "最近项目",
"usesServerDir": "使用服务器的工作目录"
"usesServerDir": "使用服务器的工作目录",
"pathPlaceholder": "请输入路径..."
},
"directoryBrowserSheet": {
"noActiveConnection": "没有活动连接",
@@ -434,6 +448,12 @@
"hoursAgo": "{{count}} 小时前",
"daysAgo": "{{count}} 天前"
}
},
"messageBubble": {
"roleYou": "你",
"roleAssistant": "AI 助手",
"tokensSuffix": " tokens",
"costPrefix": "$"
}
},
"demo": {