Skip to content

refactor(server): route requests through a server context and type core sockets - #2403

Merged
devswha merged 2 commits into
mainfrom
refactor/server-seams
Oct 30, 2026
Merged

devswha merged 2 commits into
mainfrom
refactor/server-seams

Conversation

@devswha

@devswha devswha commented Oct 29, 2026 •

Copy link
Copy Markdown
Owner

Change

Summary

Seams for an upcoming end-to-end-encrypted relay transport. No behaviour change.

Changes

  • handleRequest / ServerContext extraction: request routing moves out of server/index.ts into server/dispatch.ts, with a dispatch test.
  • SocketLike (including readyState): core sockets are typed by the four members they use.
  • A local RelayClient type in server/machine-relay.ts.
  • A Bun WebSocketHandler wrapper in server/core.

Move-only note

git diff --color-moved origin/main...HEAD -- server/index.ts server/dispatch.ts shows moved code plus context plumbing. The SocketLike commit is type-only, so this PR is not strictly move-only and waits for a normal review.

No changelog fragment: there is no user-visible change.

Validation

Ran on this branch after rebasing onto origin/main 510b598 (head 5b92070):

  • bun install --frozen-lockfile: no changes
  • bun run typecheck: pass
  • timeout 1800 bun run check fast: exit 0 (workflow syntax, generated types, generated themes, typecheck, build, unit tests 9084 pass / 0 fail / 36 skip in 583 files)
  • git diff --color-moved=dimmed-zebra origin/main...HEAD: only moved code, import paths, the bunServer -> ctx context plumbing, the declared SocketLike narrowing and the new server/dispatch.test.ts. Main's new fetch-body changes (statusline route and bridge path, /ws telemetry surface/ingress, artifacts route, prompt-answer tally, agentOffered for /api/agents) now live in server/dispatch.ts; agentOffered reaches it as one more RequestServices field.

Skipped here (run in CI): Integration and browser, Native Windows install.

@coderabbitai

coderabbitai Bot commented Oct 29, 2026 •

Copy link
Copy Markdown

Review in Change Stack →

📝 Summary

Summary by CodeRabbit

  • New Features
    • Added a health-check endpoint and clearer responses for unknown API routes.
    • Requests now enforce device access limits, including restrictions on what watch devices can view or change.
  • Bug Fixes
    • Improved handling of server startup failures so requests receive an error response instead of proceeding through normal routing.

The server's HTTP request flow now resides in a dedicated dispatcher supplied with per-server services and transport context. WebSocket clients and handlers use local structural interfaces, and test mocks provide the members required by those types.

Changes: HTTP request dispatch

Layer / File(s) Summary
Dispatch context and entrypoint wiring
server/dispatch.ts, server/index.ts, server/workspace-routes.ts
The dispatcher defines service and transport context interfaces. The server entrypoint builds and exposes this context and delegates requests to handleRequest. Workspace routes use the context's timeout capability.
Startup, access checks, and machine routes
server/dispatch.ts
The dispatcher handles startup failures, request access checks, timeouts, and bridge, machine, and WebSocket routes.
Service routes and fallback responses
server/dispatch.ts, server/dispatch.test.ts
The dispatcher routes service, health, and session requests. Unknown API paths return JSON 404 responses; other unmatched paths are served as static files. Tests cover health and unknown API responses.

Structural WebSocket types

Layer / File(s) Summary
Structural client types
server/core/connections.ts, server/machine-relay.ts
Client now aliases the exported SocketLike interface. MachineRelay uses a local RelayClient interface instead of Bun's ServerWebSocket type.
Socket callback types and server wiring
server/core/socket.ts, server/core/server-core.ts
createSocket returns SocketHandler, whose lifecycle callbacks accept Client. Server-core forwards those callbacks through a Bun WebSocketHandler.
Client test fixtures
server/core/*.test.ts, server/core/status.fake.ts
Test client objects provide the required socket members directly, replacing type assertions in the updated fixtures.

Priority: Low

Estimated code review effort: 4 (Complex) | ~45 minutes

Change: Refactor

Merge Risk: Minimal · up to cf70e

No actionable issue is established; the change appears ready for normal checks.

Pre-merge checks: Passed 4 · Failed 1

Failed Failed checks: 1 warning

Check name Status Explanation Resolution
Docstring Coverage Warning Docstring coverage is 50.00% which is insufficient. The required threshold is 80.00%. Docstring coverage is scoped to functions touched by this diff with cyclomatic complexity of at least 2. Documenta… Write docstrings for the functions missing them to satisfy the coverage threshold.
Passed Passed checks: 4 passed
Check name Status Explanation
Title check Passed The title clearly and concisely identifies the two main changes: routing requests through a server context and adding core socket types.
Linked Issues check Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check Passed Check skipped because no linked issues were found for this pull request.
Description check Passed The description includes the required Change and Validation sections. It explains the routing and socket-typing changes, states the expected behavior, lists commands with results, and identifies skipp…
Full details: Description check

Explanation

The description includes the required Change and Validation sections. It explains the routing and socket-typing changes, states the expected behavior, lists commands with results, and identifies skipped checks. It does not explicitly report HTTP/WS contract-test coverage, but the required information is otherwise mostly complete.

Full details: Docstring Coverage

Explanation

Docstring coverage is 50.00% which is insufficient. The required threshold is 80.00%. Docstring coverage is scoped to functions touched by this diff with cyclomatic complexity of at least 2. Documentation-only edits and deletions within surviving functions are included; unchanged diff context and fully deleted functions are excluded. Analyzed 8 functions across 15 files.

Resolution

Write docstrings for the functions missing them to satisfy the coverage threshold.

Finishing Touches: 💡 1
📝 Generate docstrings: 💡
  • Commit to this branch
  • Create a new PR
🧪 Generate unit tests (beta)
  • Commit to this branch
  • Create a new PR
  • Autofix · Keep fixing CodeRabbit findings and required CI, and resolving merge conflicts

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@devswha devswha mentioned this pull request Oct 30, 2026
devswha and others added 2 commits October 30, 2026 15:11
…ontext

Move the fetch pipeline without changing request order, responses, headers, authentication, timeouts, upgrades or Portal startup gating. Keep only the socket peer variant; relay authentication is added by todo 8.

The context carries the existing per-server services and bound socket operations. Workspace routes receive only Pick<ServerContext, "timeout">. Add two isolated fake-context acceptance tests.

Pre-move bunServer references (the tool LSP failed on TypeScript 7; a read-only typescript-language-server probe using installed typescript-api recovered all 14 semantic references):
server/index.ts:294:26
server/index.ts:313:18
server/index.ts:407:58
server/index.ts:425:11
server/index.ts:457:30
server/index.ts:463:26
server/index.ts:497:24
server/index.ts:505:79
server/index.ts:515:73
server/index.ts:516:84
server/index.ts:518:78
server/index.ts:579:48
server/index.ts:607:86
server/index.ts:641:193

Complete bunServer text inventory, including the callee parameter:
../../herdr-web-ui-wt/refactor-server-dispatch/server/index.ts
294:     async fetch(request, bunServer) {
313:       const ip = bunServer.requestIP(request);
407:       if (pathname.startsWith("/api/") && authenticated) bunServer.timeout(request, 60);
425:           bunServer.timeout(request, pathname === "/api/machines/events" ? 0 : slowMachinePath(pathname.split("/").slice(4).join("/")) ? 180 : 80);
457:             const upgraded = bunServer.upgrade(request, { headers, data: connectionData({ relay, deviceId, readOnly }) });
463:         const upgraded = bunServer.upgrade(request, { headers, data: connectionData({ deviceId, readOnly, bridge: bridgeAuthorized }) });
497:           const port = bunServer.port ?? DEFAULT_PORT;
505:         return handlePortalRequest(request, pathname, options.portal, { port: bunServer.port ?? DEFAULT_PORT, ...portalGuards() });
515:       if (pathname === "/api/usage/login") { setRequestRoute(pathname); bunServer.timeout(request, 30); return usageLogin.handle(request, collector.trackPane); }
516:       if (pathname === "/api/cliproxy" || pathname.startsWith("/api/cliproxy/")) { bunServer.timeout(request, 60); return cliProxyRoutes.handle(request); }
518:       if (pathname === "/api/voice" || pathname.startsWith("/api/voice/")) { bunServer.timeout(request, 120); return handleVoiceRequest(request, pathname, voice); }
579:         return jsonResponse(await remoteAccess(bunServer.port ?? DEFAULT_PORT), 200, { "cache-control": "no-store" });
607:       const workspaceResponse = await handleWorkspaceRequest(request, url, pathname, bunServer, broadcastAll, queueFirstMessage, () => {
641:         return handleSessionResumeRequest(request, { readOnly, stores: { codexHome: options.codexHome, opencodeDb: options.opencodeDb, hermesHome: options.hermesHome }, holdOpen: (seconds) => bunServer.timeout(request, seconds) });
../../herdr-web-ui-wt/refactor-server-dispatch/server/workspace-routes.ts
39: export async function handleWorkspaceRequest(request: Request, url: URL, pathname: string, bunServer: Pick<Server<unknown>, "timeout">, broadcastAll: (message: ServerMessage) => void,
137:     if (payload.agent) bunServer.timeout(request, 75);
193:     bunServer.timeout(request, 75);
242:     if (creating) bunServer.timeout(request, agent ? 150 : 75);

Complete pre-move rg -n "requestIP|\.timeout\(|\.upgrade\(" server inventory (58 hits; includes test fixtures and AbortSignal deadlines):
server/codex-daemon.test.ts:55:    fetch(request, bun) { return bun.upgrade(request) ? undefined : new Response("no", { status: 400 }); },
server/grok.test.ts:298:      const running = once(child.stdout!, "data", { signal: AbortSignal.timeout(10_000) });
server/grok.test.ts:337:        const exited = once(child, "exit", { signal: AbortSignal.timeout(2000) });
server/machines-refresh.contract.test.ts:51:              if (new URL(request.url).pathname === "/ws" && server.upgrade(request, { data: undefined })) return;
server/remote-bundle.ts:49:  const response = await fetch(url, { signal: AbortSignal.timeout(30 * 60_000) });
server/telemetry.ts:385:              signal: AbortSignal.any([abort.signal, AbortSignal.timeout(SEND_TIMEOUT_MS)]),
server/telemetry.ts:431:          signal: AbortSignal.any([abort.signal, AbortSignal.timeout(SEND_TIMEOUT_MS)]),
server/request-log.contract.test.ts:98:      if (server.upgrade(request)) return;
server/portal.ts:579:      const response = await (this.options.fetch ?? fetch)(`${relay}/sdk/domain`, { signal: AbortSignal.timeout(RELAY_TIMEOUT_MS), redirect: "error" });
server/session-history.test.ts:1433:          server.timeout(incoming, 4);
server/session-history.test.ts:1434:          return handleSessionResumeRequest(new Request(incoming.url, { method: "POST", body }), { readOnly: false, ...base, tabs, holdOpen: (seconds: number) => { asked.push(seconds); server.timeout(incoming, seconds); } });
server/session-history.test.ts:1475:        fetch: (incoming) => handleSessionResumeRequest(incoming, { readOnly: false, ...base, tabs, deadline: started.promise, holdOpen: (seconds: number) => server.timeout(incoming, seconds) }),
server/device-access.contract.test.ts:380:      if (instance.upgrade(request)) return;
server/device-access.contract.test.ts:715:    if (instance.upgrade(request, { data: { relay } })) return;
server/machine-relay.test.ts:62:        fetch(request, server) { return server.upgrade(request) ? undefined : new Response("no", { status: 400 }); },
server/machine-relay.test.ts:100:      fetch(request, server) { return server.upgrade(request) ? undefined : new Response("no", { status: 400 }); },
server/machine-relay.test.ts:146:      fetch(request, server) { return server.upgrade(request) ? undefined : new Response("no", { status: 400 }); },
server/cliproxy.ts:114:        ...init, redirect: "error", signal: AbortSignal.timeout(8000),
server/bridge.test.ts:210:        if (new URL(request.url).pathname === "/ws") return srv.upgrade(request) ? undefined : new Response("no", { status: 400 });
server/bridge.test.ts:330:    await expect(fetch(`http://127.0.0.1:${relayTo(closed)}/`, { signal: AbortSignal.timeout(5000) })).rejects.toThrow();
server/usage-login.ts:288:        env: this.env, stdout: "pipe", stderr: "ignore", signal: AbortSignal.timeout(10_000),
server/push.ts:574:        signal: AbortSignal.timeout(PUSH_TIMEOUT_MS),
server/omo-status.test.ts:560:    const health = () => fetch(`http://127.0.0.1:${server.port}/api/health?scope=bridge`, { signal: AbortSignal.timeout(5000) });
server/workspace-routes.ts:137:    if (payload.agent) bunServer.timeout(request, 75);
server/workspace-routes.ts:193:    bunServer.timeout(request, 75);
server/workspace-routes.ts:242:    if (creating) bunServer.timeout(request, agent ? 150 : 75);
server/machine-update.test.ts:77:            if (path === "/ws" && server.upgrade(request)) return;
server/usage.ts:246:  const response = await ctx.fetch(url, { ...init, signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS) });
server/pi-ask-bridge.ts:109:    const timeout = AbortSignal.timeout(STATE_READ_MS);
server/pi-ask-bridge.ts:124:      signal: AbortSignal.any([signal, AbortSignal.timeout(SUBMIT_MS)]),
server/windows-native.test.ts:270:    await windowsHost.installBundle(ssh, "win32-x64", AbortSignal.timeout(60_000));
server/windows-native.test.ts:275:    await expect(windowsHost.installBundle(ssh, "win32-x64", AbortSignal.timeout(60_000))).rejects.toThrow();
server/machines.ts:1038:      signal: signal ? AbortSignal.any([signal, AbortSignal.timeout(BRIDGE_ANSWER_MS)]) : AbortSignal.timeout(BRIDGE_ANSWER_MS) });
server/machines.ts:1114:      signal: AbortSignal.any([job.abort.signal, AbortSignal.timeout(BRIDGE_ANSWER_MS)]),
server/machines.ts:1179:    const deadline = AbortSignal.timeout(BRIDGE_ANSWER_MS);
server/machines.ts:1221:          const r = await fetch(endpoint.url + "/api/session", { headers: { authorization: `Bearer ${endpoint.token}` }, signal: AbortSignal.timeout(15_000) });
server/machines.ts:1404:        const response = await fetch(`${endpoint.url}/api/health`, { headers: { authorization: `Bearer ${endpoint.token}` }, signal: AbortSignal.any([abort.signal, AbortSignal.timeout(15_000)]) });
server/machine-diagnostics.test.ts:88:        if (server.upgrade(request)) return;
server/supervisor.ts:142:        const response = await fetch(`${origin}/api/health`, { signal: AbortSignal.timeout(1000) });
server/machine-api.ts:355:        const response = await fetch(target, { method: request.method, headers, body: ["GET", "HEAD"].includes(request.method) ? undefined : request.body, redirect: "error", signal: AbortSignal.any([request.signal, abort.signal, AbortSignal.timeout(slowMachinePath(path) ? 165_000 : 75_000)]) });
server/codex-daemon.contract.test.ts:22:    fetch(request, bun) { return bun.upgrade(request) ? undefined : new Response("no", { status: 400 }); },
server/ping.contract.test.ts:65:      if (instance.upgrade(request, { data: { relay } })) return;
server/index.ts:313:      const ip = bunServer.requestIP(request);
server/index.ts:407:      if (pathname.startsWith("/api/") && authenticated) bunServer.timeout(request, 60);
server/index.ts:425:          bunServer.timeout(request, pathname === "/api/machines/events" ? 0 : slowMachinePath(pathname.split("/").slice(4).join("/")) ? 180 : 80);
server/index.ts:457:            const upgraded = bunServer.upgrade(request, { headers, data: connectionData({ relay, deviceId, readOnly }) });
server/index.ts:463:        const upgraded = bunServer.upgrade(request, { headers, data: connectionData({ deviceId, readOnly, bridge: bridgeAuthorized }) });
server/index.ts:515:      if (pathname === "/api/usage/login") { setRequestRoute(pathname); bunServer.timeout(request, 30); return usageLogin.handle(request, collector.trackPane); }
server/index.ts:516:      if (pathname === "/api/cliproxy" || pathname.startsWith("/api/cliproxy/")) { bunServer.timeout(request, 60); return cliProxyRoutes.handle(request); }
server/index.ts:518:      if (pathname === "/api/voice" || pathname.startsWith("/api/voice/")) { bunServer.timeout(request, 120); return handleVoiceRequest(request, pathname, voice); }
server/index.ts:641:        return handleSessionResumeRequest(request, { readOnly, stores: { codexHome: options.codexHome, opencodeDb: options.opencodeDb, hermesHome: options.hermesHome }, holdOpen: (seconds) => bunServer.timeout(request, seconds) });
server/machines-refresh.test.ts:159:        if (server.upgrade(request)) return;
server/machine-own-update.test.ts:157:      if (path === "/ws" && server.upgrade(request, { data: undefined })) return;
server/prompt.ts:1154:      const deadline = AbortSignal.any([request.signal, AbortSignal.timeout(4_000)]);
server/opencode-service.ts:106:      signal: AbortSignal.any([AbortSignal.timeout(OPENCODE_HTTP_TIMEOUT_MS), ...(signal ? [signal] : [])]),
server/outcome.contract.test.ts:56:  const event = (name: string) => once(events, name, { signal: AbortSignal.timeout(10_000) });
server/outcome.contract.test.ts:78:    await once(frames, "snapshot", { signal: AbortSignal.timeout(10_000) });
server/outcome.contract.test.ts:81:      const result = once(frames, `submit-${requestId}`, { signal: AbortSignal.timeout(10_000) });

Validation so far
Full bun test server/ and bun run check fast are assigned to the combined-branch PR-A gate by the orchestrator. Incomplete runs were canceled and their processes verified gone; no final counts are claimed for them.
Targeted baseline: timeout 1200 bun test server/api.contract.test.ts server/device-access.contract.test.ts server/request-log.contract.test.ts server/page-media.contract.test.ts server/output.contract.test.ts -> 220 pass, 6 fail, 0 skip, 226 total, exit 1.
Targeted after: same plus server/dispatch.test.ts -> 224 pass, 4 fail, 0 skip, 228 total, exit 1; no new failure names.
Typecheck: bun run typecheck -> exit 0.
Flakiness: timeout 120 bun test server/dispatch.test.ts -> 2 pass, 0 fail, 0 skip, exit 0 on each of two runs.
Manual QA: 200 before/after health; 403 before/after evil-origin POST.

Co-authored-by: sisyphus-dev-ai <sisyphus-dev-ai@users.noreply.github.com>
Co-authored-by: sisyphus-dev-ai <sisyphus-dev-ai@users.noreply.github.com>
@devswha
devswha force-pushed the refactor/server-seams branch from cf70e9a to 5b92070 Compare October 30, 2026 06:13
@devswha

devswha commented Oct 30, 2026

Copy link
Copy Markdown
Owner Author

Rebased onto origin/main (510b598); conflicts in server/index.ts (imports and the request handler) resolved by moving main's changes into the moved code in server/dispatch.ts (statusline route, /ws surface/ingress telemetry, artifacts route, prompt-answer tally, agentOffered passed through RequestServices); still move-only (git diff --color-moved shows only moved code and import paths, plus the declared context plumbing and SocketLike narrowing); bun run check fast green.

@devswha

devswha commented Oct 30, 2026

Copy link
Copy Markdown
Owner Author

Merging 5b92070 (the maintainer's refactor, merged first so the open server PRs build on it). Requests are routed through a server context and the core sockets are typed (SocketLike). Reviewed by the merge pass: not strictly move-only (it adds socket-context adapters, service bindings, a returned context and WebSocket callback wrappers), but no HTTP or WebSocket behaviour change was found: SocketLike keeps the original socket and its data, every consumed field is preserved, and no authorization, origin, observe, backpressure or shutdown check is skipped. check fast passes (9,084 unit tests); the API contract tests pass except four fixture-startup failures that reproduce identically on main. CodeRabbit not required for a maintainer refactor; open threads: none.

@devswha
devswha merged commit 1a0e441 into main Oct 30, 2026
21 checks passed
@devswha
devswha deleted the refactor/server-seams branch October 30, 2026 06:29
devswha added a commit to rabbitson87/herdr-web-ui that referenced this pull request Oct 30, 2026
Main moved the request handler into server/dispatch.ts (devswha#2403): the
portal identity passed to decideAccess, the owner_only refusal of a
Portal visitor's standing access and auth.portal in health move there
unchanged.
devswha added a commit that referenced this pull request Oct 30, 2026
## Release 0.4.21

The release PR, prepared by step 1 of
[docs/development.md#releasing](https://github.com/devswha/ovis/blob/main/docs/development.md#releasing),
now re-folded on main `1ef6ea329108a0985b823e8235b03daddcd0a5f4` after
the owner held 0.4.21 for more work. The merge pass reported the 0.4.21
queue empty.

**Do not merge yet:** the owner judges the final build before release.
No tag and no Release run from this PR's author.

- **Version:** `version` is 0.4.21 in `package.json` and
`herdr-plugin.toml`.
- **Changelog:** `bun scripts/changelog-fold.ts 0.4.21 2026-10-30`
folded 63 fragments into the 2026-10-30 section of `CHANGELOG.md` (71
entries), linked their PRs and deleted the fragments; `changelog.d`
holds only `README.md`.
- `by @login` on every outside contributor's PR link: #2361 @eddieparc,
#2333 @IdrisGit, #2355 @imurfutur3, #2341 @sulthonzh, #2359 @YuuuXie,
#2377 @IdrisGit, #2269 @CharleoYang, #2405 @jpcarranza94, #2414
@homebodify, #2417 @cheol-95, #2418 @eddieparc, #2435 @cheol-95.
- Two outside PRs had no fragment and got an entry by hand: #2341
(OpenCode background commands, Added) and #2359 (installer test timing,
Maintenance).
- Issue reporters are credited in their entries: @blaker3115-Agri
(#1488), @Hyd3-14 (#2026), @osamahbeig (#2343), @Shunamxiao (#2329),
@matiemer (#2362), @kilhyeonjun (#2344), @eddieparc (#2360), @Ploppy3
(#2236), @sulthonzh (#2340), @WOULDU-pres (#2056), @odfalik (#2038),
@aNNdii (#511), @Noobear (#2281, fixed by #2424); #2435 fixes
@cheol-95's own #2434.
- Two fragments ran past the changelog's width (#2418, #2269); their
entries are re-wrapped, words unchanged. #2382's entry reads "OpenCode's
installer puts its folder first" (reviewed earlier on this PR).
- Maintainer PRs without a fragment and without a user-facing change
have no entry: #2353 (check tool), #2371 (guide docs), #2396 and #2397
(tests), #2403 and #2433 (server and client transport seams, no
behaviour change), #2437 (test-only fix for #2435's check). #2438's own
entry (the sidebar selection marked by its fill again) is dropped: the
stroke #2352 added never shipped, so users see no change; #2352's entry
keeps only the Windows high-contrast Highlight bar.
- **Release summaries:** en, ko, ja and zh each have 5 new, 5 improved
and 5 fixed lines. Since the first review: New lists the in-browser
Claude Code / Codex sign-in (#2428) in place of the tab/workspace
shortcut line; Improved lists Settings → Agents (#2427) in place of the
sidebar fold line; Fixed lists the Codex wrong-session fix (#2426,
#2430) in place of the phone screenshot paste line. The Artifacts and
Claude status-line lines keep their reviewed wording (turned on in
Settings). Not in 0.4.21: #2420 (Codex chat behind Kiro), #2065 (Portal
sign-in) and #2436 (relay foundations) stay open for a later release.
- **Remote bundle:** `REMOTE_BUNDLE_VERSION` rises from 39 to 40 because
the bundle inputs (`server/`, `shared/`, `patches/`) changed since
remote-v39. After merge: push `remote-v40` on the merged commit and wait
for the Remote PC bundles workflow before running Release.
- **iOS:** `CURRENT_PROJECT_VERSION` stays 3 (#2370 changed
`Info.plist`; build 3 is not known to be uploaded yet).

## Validation

- `bun scripts/release-guards.ts --base v0.4.20`: "Release 0.4.21:
remote bundle, telemetry Worker and release metadata are in order."
- `dori heavy … bun run check fast` on this branch: workflow syntax,
generated types and themes, typecheck, build, unit tests all ok. Unit:
9166 pass, 0 fail, 36 skip in 591 files.
- Every PR in `v0.4.20..main` was pre-run for the owner's hands-on guide
on isolated copies (own herdr session, fresh state, never the owner's
live session). Items that need a real device (iPhone tap dictation
#2370, dictation keyboards #2386, the installed desktop app's title-bar
overlay #2381) are left for the owner's final check.

This is a release-metadata PR: it changes no code path. Per the release
steps, merge it after CI and the owner's go, then push `remote-v40`,
wait for its bundles, and run Release with `0.4.21`.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant