Skip to content

Commit 7f7ad3b

Browse files
committed
Merge remote-tracking branch 'upstream/dev' into review/pr-744-antigravity
2 parents cbd3ca0 + 18352b4 commit 7f7ad3b

86 files changed

Lines changed: 3058 additions & 652 deletions

File tree

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

.github/workflows/ci.yml

Lines changed: 107 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -40,30 +40,125 @@ on:
4040
permissions:
4141
contents: read
4242

43+
# Retrigger CI after dir-fsync / oauth deadline follow-ups (tip 34a1ac46).
4344
concurrency:
4445
group: cross-platform-ci-${{ github.ref }}
4546
cancel-in-progress: true
4647

4748
jobs:
49+
# Which Windows runner this run is allowed to use.
50+
#
51+
# READ THIS BEFORE TREATING IT AS A SECURITY BOUNDARY: it is not one.
52+
#
53+
# On `pull_request` this workflow is loaded from the PR head, so the `case`
54+
# below is owned by the proposed patch exactly like an `if:` guard would be.
55+
# A hostile PR can delete the branch and hardcode the self-hosted labels into
56+
# `$GITHUB_OUTPUT`, and `runs-on` will honour it. That this job runs on
57+
# `ubuntu-latest` changes nothing — the untrusted part is its OUTPUT, not its
58+
# host. `.github/workflows/ci.yml` is in this workflow's `pull_request.paths`,
59+
# so such an edit triggers its own run.
60+
#
61+
# What actually keeps untrusted code off a self-hosted runner lives OUTSIDE
62+
# this file, where a PR cannot reach it: the fork-PR approval policy
63+
# (`all_external_contributors`) and the judgement of whoever clicks approve.
64+
# Runner groups would be the other lever, but they are an organisation
65+
# feature and this repository is user-owned, so the approval policy is the
66+
# only one available here. GitHub's own guidance is to avoid self-hosted
67+
# runners on public repositories for this reason.
68+
#
69+
# So read the routing below as a COST control that keeps honest pull requests
70+
# on GitHub-hosted runners, not as a guarantee about hostile ones.
71+
#
72+
# `push` on dev/main/preview requires the push permission, and
73+
# `workflow_dispatch` requires write access, so both carry a trusted author.
74+
# A trusted author is not audited code: merging a contributor PR into `dev`
75+
# fires `push`, and its dependencies and postinstall hooks then run here.
76+
select-windows-runner:
77+
name: select windows runner
78+
runs-on: ubuntu-latest
79+
timeout-minutes: 2
80+
outputs:
81+
runner: ${{ steps.pick.outputs.runner }}
82+
label: ${{ steps.pick.outputs.label }}
83+
steps:
84+
- name: Pick runner
85+
id: pick
86+
env:
87+
# Read through env rather than interpolating directly into the script:
88+
# `github.event_name` is a fixed vocabulary, but keeping the habit means
89+
# no future edit here can grow a script-injection sink.
90+
EVENT_NAME: ${{ github.event_name }}
91+
USE_SELF_HOSTED: ${{ vars.OCX_SELF_HOSTED_WINDOWS }}
92+
shell: bash
93+
run: |
94+
set -euo pipefail
95+
trusted=no
96+
case "$EVENT_NAME" in
97+
push|workflow_dispatch) trusted=yes ;;
98+
esac
99+
100+
# Repository variable OCX_SELF_HOSTED_WINDOWS is an OPERATIONAL switch,
101+
# not a security control: a PR that rewrites this script ignores it for
102+
# the same reason it ignores the event check above. Its job is to keep CI
103+
# working when the box is off or busy. Anything other than `1` —
104+
# including unset, the state before a runner exists — falls back to
105+
# windows-latest.
106+
if [ "$trusted" = "yes" ] && [ "${USE_SELF_HOSTED:-}" = "1" ]; then
107+
echo 'runner=["self-hosted","Windows","X64","ocx-home"]' >> "$GITHUB_OUTPUT"
108+
echo 'label=self-hosted (ocx-home)' >> "$GITHUB_OUTPUT"
109+
else
110+
echo 'runner="windows-latest"' >> "$GITHUB_OUTPUT"
111+
echo 'label=windows-latest' >> "$GITHUB_OUTPUT"
112+
fi
113+
48114
test:
49-
name: ${{ matrix.os }}
50-
runs-on: ${{ matrix.os }}
115+
name: ${{ matrix.name }}
116+
needs: select-windows-runner
117+
runs-on: ${{ matrix.runner }}
51118
# Windows dominates this matrix: on run 30459554635 the same suite took
52119
# ubuntu 4.6min / macos 5.6min / windows 11.8min. Against the previous
53120
# 12-minute ceiling that left ~12s of headroom, so runner variance decided
54121
# the result rather than the code under review — #711's rerun finished at
55122
# 11.8min and passed while #653's was killed at 12.0min (issue #717).
56123
# A cancelled job renders as `fail` in `gh pr checks`, so that flakiness
57-
# reads as a broken PR. 20 minutes keeps a green Windows run green with
58-
# real margin; it is not a licence for the suite to grow into it. If
59-
# Windows approaches this ceiling too, fix the 2.5x platform gap instead
60-
# of raising the number again.
61-
timeout-minutes: 20
124+
# reads as a broken PR. After the 2026-08-01 state-store admission merge,
125+
# Windows under `bun test --isolate` on #827 hit the 20-minute kill while
126+
# still green mid-suite (~19m of tests). 30 minutes is the margin for that
127+
# tip — not a licence to absorb hung tests (see oauth mutation waitMs
128+
# unref fix). Shrink the suite / close the platform gap rather than raising
129+
# this again for ordinary variance.
130+
timeout-minutes: 30
62131
strategy:
63132
fail-fast: false
64133
matrix:
65-
os: [ubuntu-latest, windows-latest, macos-latest]
134+
# `name` is spelled out rather than derived from `runner`: a runner given
135+
# as a label array renders as "self-hosted Windows X64 ocx-home", so the
136+
# check name would change with the routing and break any branch
137+
# protection rule that names it. Keeping `windows` fixed means the
138+
# required check stays the same whichever machine served it.
139+
include:
140+
- name: ubuntu
141+
runner: ubuntu-latest
142+
- name: windows
143+
runner: ${{ fromJSON(needs.select-windows-runner.outputs.runner) }}
144+
- name: macos
145+
runner: macos-latest
66146
steps:
147+
- name: Show selected runner
148+
if: matrix.name == 'windows'
149+
shell: bash
150+
run: echo "windows leg on ${{ needs.select-windows-runner.outputs.label }}"
151+
152+
# A self-hosted runner keeps its working directory between jobs. Without an
153+
# explicit wipe, a file deleted in the commit under test survives on disk
154+
# and the suite passes against a tree that no longer exists in git.
155+
# `--ephemeral` registration de-registers the runner after each job but does
156+
# not clean the workspace, so this step is what makes the checkout honest.
157+
- name: Clean workspace (self-hosted only)
158+
if: runner.environment == 'self-hosted'
159+
shell: bash
160+
run: git clean -xffd . || true
161+
67162
- name: Checkout
68163
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7
69164

@@ -113,6 +208,10 @@ jobs:
113208
strategy:
114209
fail-fast: false
115210
matrix:
211+
# Deliberately NOT routed to the self-hosted box. This job runs
212+
# `npm install -g`, which writes into the machine's global prefix and
213+
# would leave an `ocx` on a maintainer's personal PATH. It is an
214+
# 8-minute job, so there is nothing to win by moving it.
116215
os: [ubuntu-latest, windows-latest, macos-latest]
117216
steps:
118217
- name: Checkout

bin/ocx.mjs

Lines changed: 32 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -250,12 +250,40 @@ function runNpmSelfUpdate() {
250250
try {
251251
const svcArgs = serviceReinstallArgs();
252252
const svc = spawnSync(process.execPath, svcArgs, { stdio: "inherit", windowsHide: true });
253-
if (svc.status !== 0) {
253+
let needDirectStart = svc.status !== 0;
254+
if (!needDirectStart) {
255+
// Exit 0 can still leave stale/missing assets that never bring the proxy
256+
// back — match the GUI/CLI fallthrough so /healthz is not left dead.
257+
try {
258+
const st = spawnSync(process.execPath, [launcher, "status", "--json"], {
259+
encoding: "utf8",
260+
timeout: 20_000,
261+
windowsHide: true,
262+
});
263+
if (st.status === 0 && typeof st.stdout === "string" && st.stdout.trim()) {
264+
const parsed = JSON.parse(st.stdout);
265+
const proxyUp = parsed?.proxy?.running === true || parsed?.proxy?.health?.ok === true;
266+
const viable = parsed?.startup?.serviceViable === true;
267+
if (!proxyUp && !viable) needDirectStart = true;
268+
} else {
269+
// status failed or empty — fail closed to direct start (match CLI).
270+
needDirectStart = true;
271+
}
272+
} catch {
273+
needDirectStart = true;
274+
}
275+
}
276+
if (needDirectStart) {
254277
// On Windows, schtasks /create requires elevation. The launcher inherits the
255278
// user's (non-admin) token, so the service reinstall can fail with access
256-
// denied. Fall back to a direct detached proxy start so the update never
257-
// leaves the user without a running proxy.
258-
console.warn("opencodex: service refresh failed — starting the proxy directly instead.");
279+
// denied — or exit 0 while leaving a non-viable manager. Fall back to a
280+
// direct detached proxy start so the update never leaves the user without
281+
// a running proxy.
282+
console.warn(
283+
svc.status === 0
284+
? "opencodex: service refresh left a non-viable manager — starting the proxy directly instead."
285+
: "opencodex: service refresh failed — starting the proxy directly instead.",
286+
);
259287
console.warn(" Run 'ocx service install' as administrator to refresh the background service.");
260288
const env = { ...process.env };
261289
delete env.OCX_SERVICE;

gui/src/client-resource.ts

Lines changed: 38 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -40,6 +40,12 @@ type Store<T> = {
4040
/** Store-level visibilitychange handler, installed only while this store polls. */
4141
visibilityListener: (() => void) | null;
4242
generation: number;
43+
/**
44+
* Set when `setClientResourceData` publishes while nobody is subscribed (session-cache
45+
* seed). The first subscribe must quiet-revalidate; otherwise mount never fetches and
46+
* seeded rows stay indefinitely stale with `lastAttemptOk: true`.
47+
*/
48+
seedNeedsRevalidate: boolean;
4349
};
4450

4551
/**
@@ -80,6 +86,7 @@ function getStore<T>(key: string): Store<T> {
8086
inflightOwner: null,
8187
visibilityListener: null,
8288
generation: 0,
89+
seedNeedsRevalidate: false,
8390
};
8491
stores.set(key, store);
8592
}
@@ -223,6 +230,8 @@ async function runFetch<T>(
223230
try {
224231
const data = await fetcher(controller.signal);
225232
if (gen !== store.generation || controller.signal.aborted) return;
233+
// Cleared on settle (not at subscribe) so StrictMode's aborted first mount still revalidates.
234+
store.seedNeedsRevalidate = false;
226235
store.snapshot = {
227236
data,
228237
error: undefined,
@@ -233,6 +242,7 @@ async function runFetch<T>(
233242
};
234243
} catch (error) {
235244
if (gen !== store.generation || controller.signal.aborted) return;
245+
store.seedNeedsRevalidate = false;
236246
store.snapshot = {
237247
...store.snapshot,
238248
// Normalize so a loader that rejects with `undefined` still reads as a failure.
@@ -298,9 +308,12 @@ function subscribeResource<T>(
298308
store.fetcherByListener.set(onStoreChange, fetcher);
299309
store.subscriberCount++;
300310

301-
// Cold start only — keep cached data across transient 0→1 resubscribe gaps.
302-
if (store.subscriberCount === 1 && store.snapshot.data === undefined) {
303-
void runFetch(store, fetcher, { replaceInflight: true, owner: onStoreChange });
311+
// Cold start, or a pre-subscribe seed that still needs a network check. Keep
312+
// cached data across transient 0→1 resubscribe gaps when neither applies.
313+
if (store.subscriberCount === 1) {
314+
if (store.snapshot.data === undefined || store.seedNeedsRevalidate) {
315+
void runFetch(store, fetcher, { replaceInflight: true, owner: onStoreChange });
316+
}
304317
}
305318
recomputePoll(store);
306319

@@ -328,7 +341,7 @@ function subscribeResource<T>(
328341
}
329342

330343
/** Module-level fetch cache with useSyncExternalStore subscriptions (no fetch in useEffect). */
331-
export interface ClientResourceOptions {
344+
export interface ClientResourceOptions<T = unknown> {
332345
pollMs?: number;
333346
enabled?: boolean;
334347
/**
@@ -337,18 +350,34 @@ export interface ClientResourceOptions {
337350
* happening off-screen — a restarting server, for instance.
338351
*/
339352
pauseWhenHidden?: boolean;
353+
/**
354+
* Optional seed applied once before the first subscribe (session-cache revisit).
355+
* Lives in the store — not a render-time ref — so React Compiler / react-hooks/refs
356+
* stay quiet while the mount fetch still quiet-revalidates via `seedNeedsRevalidate`.
357+
*/
358+
initialData?: T;
359+
}
360+
361+
/** Seed an empty, unsubscribed store. No-ops when data already exists or someone is listening. */
362+
function seedClientResourceIfEmpty<T>(key: string, data: T): void {
363+
const store = getStore<T>(key);
364+
if (store.subscriberCount !== 0 || store.snapshot.data !== undefined) return;
365+
setClientResourceData(key, data);
340366
}
341367

342368
export function useClientResource<T>(
343369
key: string,
344370
fetcher: (signal: AbortSignal) => Promise<T>,
345-
options?: ClientResourceOptions,
371+
options?: ClientResourceOptions<T>,
346372
): ResourceSnapshot<T> & { refresh: (opts?: { forceLoading?: boolean }) => void } {
347373
const enabled = options?.enabled !== false;
348374
const pollMs = options?.pollMs;
349375
// Default true: a background tab has nobody reading the paint. Opt out for polls that
350376
// must keep running while hidden, such as waiting for a restarted server to answer.
351377
const pauseWhenHidden = options?.pauseWhenHidden !== false;
378+
if (enabled && options?.initialData !== undefined) {
379+
seedClientResourceIfEmpty(key, options.initialData);
380+
}
352381
const fetcherRef = useRef(fetcher);
353382
// Sync latest fetcher every commit. No dep array on purpose: inline fetchers are
354383
// reallocated every render; listing them would re-subscribe forever.
@@ -411,7 +440,7 @@ export function useKeyedClientResource<T>(
411440
key: string,
412441
deps: readonly unknown[],
413442
load: (signal: AbortSignal) => Promise<T>,
414-
options?: ClientResourceOptions,
443+
options?: ClientResourceOptions<T>,
415444
): ResourceSnapshot<T> & { refresh: (opts?: { forceLoading?: boolean }) => void } {
416445
const resource = useClientResource(key, load, options);
417446
const prevDepsRef = useRef<readonly unknown[] | null>(null);
@@ -447,6 +476,9 @@ export function setClientResourceData<T>(key: string, data: T) {
447476
hasSucceeded: true,
448477
lastAttemptOk: true,
449478
};
479+
// Only pre-subscribe seeds need a follow-up fetch. Live publishers (mutation
480+
// results) already hold the fresh value and must not schedule a redundant GET.
481+
store.seedNeedsRevalidate = store.subscriberCount === 0;
450482
emit(store);
451483
}
452484

gui/src/components/CodexAccountPool.tsx

Lines changed: 0 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,6 @@ import CodexPoolStrategySetting from "./CodexPoolStrategySetting";
1111
import { useCodexAutoSwitch } from "../hooks/useCodexAutoSwitch";
1212
import { readJsonIfOk } from "../fetch-json";
1313
import { CodexAccountPoolCards, CodexAccountPoolReauthBanner } from "./codex-account-pool-cards";
14-
import { DataSurfaceStatus } from "./data-surface";
1514
import { CodexAccountSwitchModal } from "./codex-account-switch-modal";
1615
import { CodexAccountResetModal } from "./codex-account-reset-modal";
1716
import { CodexAccountPoolLoadStates, CodexAccountPoolMainCard, CodexAccountPoolPageHead } from "./codex-account-pool-main-card";
@@ -57,10 +56,6 @@ export default function CodexAccountPool({ apiBase, accountModeState = null, ban
5756
const ownController = useCodexAccountPool(apiBase, !injectedController);
5857
const controller = injectedController ?? ownController;
5958
const { accounts, activeId, loadState, switchingId, pauseUpdatingId, pausingExhausted, load } = controller;
60-
// The controller owns the visible progress signal. A forced quota refresh keeps rows on screen
61-
// and can take ~1s (longer when the server has to refill per-account quota), so without this the
62-
// wait is invisible; `refreshingQuota` below stays local because it only guards its own button.
63-
const { refreshing } = controller;
6459
const [confirm, setConfirm] = useState<CodexAccountEntry | null>(null);
6560
const [showAdd, setShowAdd] = useState(false);
6661
const [reauthId, setReauthId] = useState<string | null>(null);
@@ -277,12 +272,6 @@ export default function CodexAccountPool({ apiBase, accountModeState = null, ban
277272
onRetry={() => { void load(); }}
278273
/>
279274

280-
{/* Revalidation over existing rows. The cold branch above already owns a live region, so
281-
this only renders once rows are on screen, keeping one announcement per transition. */}
282-
{refreshing && accounts.length > 0 && (
283-
<DataSurfaceStatus live={loadState !== "error"}>{t("common.loading")}</DataSurfaceStatus>
284-
)}
285-
286275
{!(loadState === "loading" && accounts.length === 0) && (
287276
<>
288277
<CodexAccountPoolMainCard

gui/src/components/apikeys-workspace/ApiKeysWorkspace.tsx

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -39,6 +39,8 @@ export interface ApiKeysWorkspaceProps {
3939
copied: boolean;
4040
filteredModels: ExternalModelRow[];
4141
modelsLoading: boolean;
42+
/** Quiet revalidation / retry over rows already on screen — not a skeleton. */
43+
modelsRefreshing?: boolean;
4244
modelsLoadFailed: boolean;
4345
modelCount: number;
4446
hasModelData: boolean;
@@ -76,6 +78,7 @@ export default function ApiKeysWorkspace({
7678
copied,
7779
filteredModels,
7880
modelsLoading,
81+
modelsRefreshing = false,
7982
modelsLoadFailed,
8083
modelCount,
8184
hasModelData,
@@ -420,6 +423,7 @@ export default function ApiKeysWorkspace({
420423
<ApiKeysModelsPanel
421424
filteredModels={filteredModels}
422425
modelsLoading={modelsLoading}
426+
modelsRefreshing={modelsRefreshing}
423427
modelsLoadFailed={modelsLoadFailed}
424428
modelCount={modelCount}
425429
hasModelData={hasModelData}

0 commit comments

Comments
 (0)